Spaces:
Sleeping
Sleeping
Restrict and implement session data deletion
Browse files- app.py +87 -2
- src/analysis.ts +38 -1
app.py
CHANGED
|
@@ -444,6 +444,89 @@ def session_row(session_id, user_id):
|
|
| 444 |
return row
|
| 445 |
|
| 446 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
@app.get("/api/sessions/{session_id}")
|
| 448 |
def results(session_id: UUID, request: Request):
|
| 449 |
row = session_row(session_id, request.state.user_id)
|
|
@@ -465,6 +548,8 @@ def results(session_id: UUID, request: Request):
|
|
| 465 |
fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
|
| 466 |
actions, failed = [], []
|
| 467 |
for index, report in enumerate(reports):
|
|
|
|
|
|
|
| 468 |
start = int(report.get("start_frame") or 0)
|
| 469 |
end = int(report.get("end_frame") or start)
|
| 470 |
action_fps = float(report.get("fps") or (fps_values[0] if fps_values else 30))
|
|
@@ -509,8 +594,7 @@ def results(session_id: UUID, request: Request):
|
|
| 509 |
"url": f"/storage/{session_id}/videos/{filename}",
|
| 510 |
"poster": f"/storage/{session_id}/posters/camera_{camera_id}.webp"}
|
| 511 |
for camera_id, filename in camera_files]
|
| 512 |
-
|
| 513 |
-
action_id = f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
|
| 514 |
actions.append({"id": action_id, "label": label,
|
| 515 |
"start": str(start), "end": str(end), "fps": action_fps,
|
| 516 |
"startFrame": start, "endFrame": end, "startSeconds": start / action_fps,
|
|
@@ -537,6 +621,7 @@ def results(session_id: UUID, request: Request):
|
|
| 537 |
created_at = created_at.replace(tzinfo=timezone.utc)
|
| 538 |
return {"id": str(session_id), "playerId": row["player_id"], "playerName": "",
|
| 539 |
"name": row.get("name", "Analysis session"),
|
|
|
|
| 540 |
"createdAt": int(created_at.timestamp() * 1000),
|
| 541 |
"targetSize": [0, 0], "cameraCount": len(camera_files), "actions": actions, "failedActions": failed}
|
| 542 |
|
|
|
|
| 444 |
return row
|
| 445 |
|
| 446 |
|
| 447 |
+
def result_action_id(session_id, index):
|
| 448 |
+
digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
|
| 449 |
+
return f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def owned_session_row(session_id, user_id):
|
| 453 |
+
row = backend(f"/{session_id}", user_id)
|
| 454 |
+
if row.get("user_id") != user_id:
|
| 455 |
+
raise HTTPException(404, "Session not found")
|
| 456 |
+
return row
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def upload_session_results(path_in_repo, payload, commit_message):
|
| 460 |
+
from io import BytesIO
|
| 461 |
+
|
| 462 |
+
hub().upload_file(
|
| 463 |
+
path_or_fileobj=BytesIO(payload),
|
| 464 |
+
path_in_repo=path_in_repo,
|
| 465 |
+
repo_id=os.environ["STORAGE_REPO_ID"],
|
| 466 |
+
repo_type="dataset",
|
| 467 |
+
commit_message=commit_message,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
@app.delete("/api/sessions/{session_id}/actions/{action_id}")
|
| 472 |
+
def delete_action(session_id: UUID, action_id: UUID, request: Request):
|
| 473 |
+
row = owned_session_row(session_id, request.state.user_id)
|
| 474 |
+
if row.get("path") != f"sessions/{session_id}":
|
| 475 |
+
raise HTTPException(409, "Session results are not available")
|
| 476 |
+
|
| 477 |
+
result_path = f'{row["path"]}/results.json'
|
| 478 |
+
downloaded_path = hf_hub_download(
|
| 479 |
+
repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 480 |
+
filename=result_path, token=os.environ["HF_TOKEN"],
|
| 481 |
+
)
|
| 482 |
+
original_payload = Path(downloaded_path).read_bytes()
|
| 483 |
+
reports = json.loads(original_payload.decode("utf-8"))
|
| 484 |
+
matching_index = next((
|
| 485 |
+
index for index, report in enumerate(reports)
|
| 486 |
+
if not report.get("error") and not report.get("_deleted")
|
| 487 |
+
and result_action_id(session_id, index) == str(action_id)
|
| 488 |
+
), None)
|
| 489 |
+
if matching_index is None:
|
| 490 |
+
raise HTTPException(404, "Action not found")
|
| 491 |
+
|
| 492 |
+
# Keep a tombstone so action IDs based on pipeline report indexes remain
|
| 493 |
+
# stable for every action that follows the deleted one.
|
| 494 |
+
reports[matching_index]["_deleted"] = True
|
| 495 |
+
updated_reports = reports
|
| 496 |
+
updated_payload = json.dumps(json_value(updated_reports), allow_nan=False).encode("utf-8")
|
| 497 |
+
upload_session_results(result_path, updated_payload, f"Delete action {action_id}")
|
| 498 |
+
try:
|
| 499 |
+
backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "DELETE")
|
| 500 |
+
except Exception:
|
| 501 |
+
try:
|
| 502 |
+
upload_session_results(result_path, original_payload, f"Restore action {action_id} after delete failure")
|
| 503 |
+
except Exception:
|
| 504 |
+
logger.exception("Could not restore session results after action deletion failed: session=%s action=%s", session_id, action_id)
|
| 505 |
+
raise
|
| 506 |
+
return {"ok": True}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
@app.delete("/api/sessions/{session_id}")
|
| 510 |
+
def delete_session(session_id: UUID, request: Request):
|
| 511 |
+
row = owned_session_row(session_id, request.state.user_id)
|
| 512 |
+
expected_path = f"sessions/{session_id}"
|
| 513 |
+
storage_path = row.get("path")
|
| 514 |
+
if storage_path is not None and storage_path != expected_path:
|
| 515 |
+
raise HTTPException(409, "Session uses a different storage layout")
|
| 516 |
+
|
| 517 |
+
if storage_path == expected_path:
|
| 518 |
+
prefix = f"{storage_path}/"
|
| 519 |
+
existing_files = hub().list_repo_files(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset")
|
| 520 |
+
if any(file_path.startswith(prefix) for file_path in existing_files):
|
| 521 |
+
hub().delete_folder(
|
| 522 |
+
repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 523 |
+
path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
backend(f"/{session_id}", request.state.user_id, "DELETE")
|
| 527 |
+
return {"ok": True}
|
| 528 |
+
|
| 529 |
+
|
| 530 |
@app.get("/api/sessions/{session_id}")
|
| 531 |
def results(session_id: UUID, request: Request):
|
| 532 |
row = session_row(session_id, request.state.user_id)
|
|
|
|
| 548 |
fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
|
| 549 |
actions, failed = [], []
|
| 550 |
for index, report in enumerate(reports):
|
| 551 |
+
if report.get("_deleted"):
|
| 552 |
+
continue
|
| 553 |
start = int(report.get("start_frame") or 0)
|
| 554 |
end = int(report.get("end_frame") or start)
|
| 555 |
action_fps = float(report.get("fps") or (fps_values[0] if fps_values else 30))
|
|
|
|
| 594 |
"url": f"/storage/{session_id}/videos/{filename}",
|
| 595 |
"poster": f"/storage/{session_id}/posters/camera_{camera_id}.webp"}
|
| 596 |
for camera_id, filename in camera_files]
|
| 597 |
+
action_id = result_action_id(session_id, index)
|
|
|
|
| 598 |
actions.append({"id": action_id, "label": label,
|
| 599 |
"start": str(start), "end": str(end), "fps": action_fps,
|
| 600 |
"startFrame": start, "endFrame": end, "startSeconds": start / action_fps,
|
|
|
|
| 621 |
created_at = created_at.replace(tzinfo=timezone.utc)
|
| 622 |
return {"id": str(session_id), "playerId": row["player_id"], "playerName": "",
|
| 623 |
"name": row.get("name", "Analysis session"),
|
| 624 |
+
"isOwner": row.get("user_id") == request.state.user_id,
|
| 625 |
"createdAt": int(created_at.timestamp() * 1000),
|
| 626 |
"targetSize": [0, 0], "cameraCount": len(camera_files), "actions": actions, "failedActions": failed}
|
| 627 |
|
src/analysis.ts
CHANGED
|
@@ -87,12 +87,49 @@ export function analysisRouter(db: SupabaseClient, secret: string) {
|
|
| 87 |
router.get("/:sessionId", async (req, res, next) => {
|
| 88 |
try {
|
| 89 |
const sessionId = z.uuid().parse(req.params.sessionId);
|
| 90 |
-
const { data, error } = await db.from("sessions").select("session_id,player_id,path,status,name,created_at")
|
| 91 |
.eq("session_id", sessionId).maybeSingle();
|
| 92 |
if (error) return res.status(503).json({ error: "Unable to load session" });
|
| 93 |
return data ? res.json(data) : res.sendStatus(404);
|
| 94 |
} catch (error) { next(error); }
|
| 95 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
router.put("/:sessionId", async (req, res, next) => {
|
| 97 |
try {
|
| 98 |
const sessionId = z.uuid().parse(req.params.sessionId);
|
|
|
|
| 87 |
router.get("/:sessionId", async (req, res, next) => {
|
| 88 |
try {
|
| 89 |
const sessionId = z.uuid().parse(req.params.sessionId);
|
| 90 |
+
const { data, error } = await db.from("sessions").select("session_id,player_id,user_id,path,status,name,created_at")
|
| 91 |
.eq("session_id", sessionId).maybeSingle();
|
| 92 |
if (error) return res.status(503).json({ error: "Unable to load session" });
|
| 93 |
return data ? res.json(data) : res.sendStatus(404);
|
| 94 |
} catch (error) { next(error); }
|
| 95 |
});
|
| 96 |
+
router.delete("/:sessionId/actions/:actionId", async (req, res, next) => {
|
| 97 |
+
try {
|
| 98 |
+
const sessionId = z.uuid().parse(req.params.sessionId);
|
| 99 |
+
const actionId = z.uuid().parse(req.params.actionId);
|
| 100 |
+
const userId = res.locals.userId as string;
|
| 101 |
+
const { data: session, error: sessionError } = await db.from("sessions")
|
| 102 |
+
.select("session_id").eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
|
| 103 |
+
if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
|
| 104 |
+
if (!session) return res.status(404).json({ error: "Session not found." });
|
| 105 |
+
|
| 106 |
+
const { data: action, error } = await db.from("actions").delete()
|
| 107 |
+
.eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
|
| 108 |
+
if (error) return res.status(503).json({ error: "Unable to delete action." });
|
| 109 |
+
if (!action) return res.status(404).json({ error: "Action not found." });
|
| 110 |
+
return res.sendStatus(204);
|
| 111 |
+
} catch (error) { next(error); }
|
| 112 |
+
});
|
| 113 |
+
router.delete("/:sessionId", async (req, res, next) => {
|
| 114 |
+
try {
|
| 115 |
+
const sessionId = z.uuid().parse(req.params.sessionId);
|
| 116 |
+
const userId = res.locals.userId as string;
|
| 117 |
+
const { data: session, error: sessionError } = await db.from("sessions")
|
| 118 |
+
.select("session_id").eq("session_id", sessionId).eq("user_id", userId).maybeSingle();
|
| 119 |
+
if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
|
| 120 |
+
if (!session) return res.status(404).json({ error: "Session not found." });
|
| 121 |
+
|
| 122 |
+
// The schema keeps actions when a session row is removed, so explicitly
|
| 123 |
+
// delete them first. Their annotations and feedback cascade from actions.
|
| 124 |
+
const { error: actionsError } = await db.from("actions").delete().eq("session_id", sessionId);
|
| 125 |
+
if (actionsError) return res.status(503).json({ error: "Unable to remove session actions." });
|
| 126 |
+
const { data: deleted, error } = await db.from("sessions").delete()
|
| 127 |
+
.eq("session_id", sessionId).eq("user_id", userId).select("session_id").maybeSingle();
|
| 128 |
+
if (error) return res.status(503).json({ error: "Unable to delete session." });
|
| 129 |
+
if (!deleted) return res.status(404).json({ error: "Session not found." });
|
| 130 |
+
return res.sendStatus(204);
|
| 131 |
+
} catch (error) { next(error); }
|
| 132 |
+
});
|
| 133 |
router.put("/:sessionId", async (req, res, next) => {
|
| 134 |
try {
|
| 135 |
const sessionId = z.uuid().parse(req.params.sessionId);
|