feng-x commited on
Commit
509e24d
·
verified ·
1 Parent(s): 3ae7fa7

Upload folder using huggingface_hub

Browse files
web_demo/app.py CHANGED
@@ -43,10 +43,13 @@ from web_demo.supabase_client import (
43
  FEEDBACK_NO_ROW,
44
  FEEDBACK_DISABLED,
45
  list_measurements,
 
46
  list_feedback,
 
47
  list_measurements_for_stats,
48
  update_ground_truth,
49
  delete_measurement,
 
50
  )
51
  from src.confidence_constants import (
52
  CONFIDENCE_LEVEL_HIGH_THRESHOLD,
@@ -647,6 +650,19 @@ def api_fit_feedback():
647
 
648
  ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "ringsizer2026")
649
 
 
 
 
 
 
 
 
 
 
 
 
 
 
650
 
651
  @app.route("/admin")
652
  def admin_page():
@@ -662,16 +678,16 @@ def _check_admin_token():
662
  def api_admin_measurements():
663
  if not _check_admin_token():
664
  return jsonify({"error": "Unauthorized"}), 401
665
- rows = list_measurements(limit=500)
666
- return jsonify(rows)
667
 
668
 
669
  @app.route("/api/admin/feedback")
670
  def api_admin_feedback():
671
  if not _check_admin_token():
672
  return jsonify({"error": "Unauthorized"}), 401
673
- rows = list_feedback(limit=500)
674
- return jsonify(rows)
675
 
676
 
677
  @app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
@@ -695,6 +711,16 @@ def api_admin_delete(measurement_id: str):
695
  return jsonify({"success": False, "error": "Delete failed"}), 400
696
 
697
 
 
 
 
 
 
 
 
 
 
 
698
  def _parse_iso_to_utc_date(iso_str: str) -> Optional[date]:
699
  """Parse a Supabase `created_at` ISO string to a UTC `date`.
700
 
 
43
  FEEDBACK_NO_ROW,
44
  FEEDBACK_DISABLED,
45
  list_measurements,
46
+ count_measurements,
47
  list_feedback,
48
+ count_feedback,
49
  list_measurements_for_stats,
50
  update_ground_truth,
51
  delete_measurement,
52
+ delete_feedback,
53
  )
54
  from src.confidence_constants import (
55
  CONFIDENCE_LEVEL_HIGH_THRESHOLD,
 
650
 
651
  ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "ringsizer2026")
652
 
653
+ # Max rows the admin list endpoints fetch in one request. The DB may hold
654
+ # more; the true total is sent in the X-Total-Count header so the front-end
655
+ # can show "newest N of M" instead of silently hiding older rows.
656
+ ADMIN_LIST_LIMIT = 500
657
+
658
+
659
+ def _with_total(resp, total: Optional[int]):
660
+ """Attach the full row count as X-Total-Count (skipped when unknown,
661
+ e.g. persistence disabled). Lets the admin UI flag a truncated list."""
662
+ if total is not None:
663
+ resp.headers["X-Total-Count"] = str(total)
664
+ return resp
665
+
666
 
667
  @app.route("/admin")
668
  def admin_page():
 
678
  def api_admin_measurements():
679
  if not _check_admin_token():
680
  return jsonify({"error": "Unauthorized"}), 401
681
+ rows = list_measurements(limit=ADMIN_LIST_LIMIT)
682
+ return _with_total(jsonify(rows), count_measurements())
683
 
684
 
685
  @app.route("/api/admin/feedback")
686
  def api_admin_feedback():
687
  if not _check_admin_token():
688
  return jsonify({"error": "Unauthorized"}), 401
689
+ rows = list_feedback(limit=ADMIN_LIST_LIMIT)
690
+ return _with_total(jsonify(rows), count_feedback())
691
 
692
 
693
  @app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
 
711
  return jsonify({"success": False, "error": "Delete failed"}), 400
712
 
713
 
714
+ @app.route("/api/admin/feedback/<feedback_id>", methods=["DELETE"])
715
+ def api_admin_delete_feedback(feedback_id: str):
716
+ if not _check_admin_token():
717
+ return jsonify({"error": "Unauthorized"}), 401
718
+ ok = delete_feedback(feedback_id)
719
+ if ok:
720
+ return jsonify({"success": True})
721
+ return jsonify({"success": False, "error": "Delete failed"}), 400
722
+
723
+
724
  def _parse_iso_to_utc_date(iso_str: str) -> Optional[date]:
725
  """Parse a Supabase `created_at` ISO string to a UTC `date`.
726
 
web_demo/supabase_client.py CHANGED
@@ -94,6 +94,47 @@ def upload_file(local_path: str, storage_path: str) -> Optional[str]:
94
  return None
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def save_measurement(record: Dict[str, Any]) -> Optional[str]:
98
  """Insert a measurement record. Returns row UUID or None."""
99
  client = _get_client()
@@ -149,6 +190,20 @@ def list_feedback(limit: int = 500, offset: int = 0) -> List[Dict[str, Any]]:
149
  return []
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
153
  """Fetch measurements for admin page, newest first."""
154
  client = _get_client()
@@ -168,6 +223,20 @@ def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]
168
  return []
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  STATS_COLUMNS = (
172
  "id,created_at,kol_name,kol_email,mode,ring_model,confidence,fail_reason,"
173
  "overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size,"
@@ -235,18 +304,46 @@ def update_measurement_feedback(run_id: str, updates: Dict[str, Any]) -> str:
235
 
236
 
237
  def delete_measurement(measurement_id: str) -> bool:
238
- """Delete a measurement record by ID."""
 
 
239
  client = _get_client()
240
  if client is None:
241
  return False
242
  try:
 
 
 
 
 
 
 
243
  client.table("measurements").delete().eq("id", measurement_id).execute()
 
 
244
  return True
245
  except Exception as e:
246
  logger.error("Failed to delete %s: %s", measurement_id, e)
247
  return False
248
 
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  GT_ALLOWED_FIELDS = {"gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_best_finger", "gt_notes"}
251
 
252
 
 
94
  return None
95
 
96
 
97
+ def _storage_path_from_public_url(public_url: str) -> Optional[str]:
98
+ """Extract the in-bucket object path from a Supabase public URL.
99
+
100
+ Public URLs look like
101
+ `<base>/storage/v1/object/public/ring-measurements/<path>`; newer
102
+ supabase-py may append a `?` query (cache-buster) which we strip.
103
+ Returns the bucket-relative `<path>` (e.g. `photos/f_x.jpg`) or None
104
+ when the URL isn't a public-object URL for our bucket (e.g. a null
105
+ photo_url on a demo run).
106
+ """
107
+ if not public_url:
108
+ return None
109
+ marker = f"/storage/v1/object/public/{BUCKET}/"
110
+ idx = public_url.find(marker)
111
+ if idx == -1:
112
+ return None
113
+ from urllib.parse import unquote
114
+ path = public_url[idx + len(marker):].split("?", 1)[0]
115
+ return unquote(path) or None
116
+
117
+
118
+ def _remove_storage_objects(public_urls: List[str]) -> None:
119
+ """Best-effort delete of storage objects behind a list of public URLs.
120
+
121
+ Called after a row delete to avoid orphaning the uploaded photo/result
122
+ objects. Never raises: the row is already gone, and a leftover object
123
+ is not worth failing the request over (failures are logged).
124
+ """
125
+ client = _get_client()
126
+ if client is None:
127
+ return
128
+ paths = [p for p in (_storage_path_from_public_url(u) for u in public_urls) if p]
129
+ if not paths:
130
+ return
131
+ try:
132
+ client.storage.from_(BUCKET).remove(paths)
133
+ logger.info("Removed %d storage object(s): %s", len(paths), paths)
134
+ except Exception as e:
135
+ logger.error("Failed to remove storage objects %s: %s", paths, e)
136
+
137
+
138
  def save_measurement(record: Dict[str, Any]) -> Optional[str]:
139
  """Insert a measurement record. Returns row UUID or None."""
140
  client = _get_client()
 
190
  return []
191
 
192
 
193
+ def count_feedback() -> Optional[int]:
194
+ """Total feedback rows, ignoring the list limit. None if persistence is
195
+ disabled (lets the admin label distinguish 'unknown' from a real 0)."""
196
+ client = _get_client()
197
+ if client is None:
198
+ return None
199
+ try:
200
+ resp = client.table("feedback").select("id", count="exact", head=True).execute()
201
+ return resp.count
202
+ except Exception as e:
203
+ logger.error("Failed to count feedback: %s", e)
204
+ return None
205
+
206
+
207
  def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
208
  """Fetch measurements for admin page, newest first."""
209
  client = _get_client()
 
223
  return []
224
 
225
 
226
+ def count_measurements() -> Optional[int]:
227
+ """Total measurement rows, ignoring the list limit. None if persistence
228
+ is disabled (lets the admin label distinguish 'unknown' from a real 0)."""
229
+ client = _get_client()
230
+ if client is None:
231
+ return None
232
+ try:
233
+ resp = client.table("measurements").select("id", count="exact", head=True).execute()
234
+ return resp.count
235
+ except Exception as e:
236
+ logger.error("Failed to count measurements: %s", e)
237
+ return None
238
+
239
+
240
  STATS_COLUMNS = (
241
  "id,created_at,kol_name,kol_email,mode,ring_model,confidence,fail_reason,"
242
  "overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size,"
 
304
 
305
 
306
  def delete_measurement(measurement_id: str) -> bool:
307
+ """Delete a measurement record by ID, sweeping its photo + result
308
+ objects from storage too (best-effort — the row delete is authoritative;
309
+ demo runs with null photo_url simply have nothing to sweep)."""
310
  client = _get_client()
311
  if client is None:
312
  return False
313
  try:
314
+ resp = (
315
+ client.table("measurements")
316
+ .select("photo_url,result_url")
317
+ .eq("id", measurement_id)
318
+ .execute()
319
+ )
320
+ rows = resp.data or []
321
  client.table("measurements").delete().eq("id", measurement_id).execute()
322
+ urls = [r.get("photo_url") for r in rows] + [r.get("result_url") for r in rows]
323
+ _remove_storage_objects(urls)
324
  return True
325
  except Exception as e:
326
  logger.error("Failed to delete %s: %s", measurement_id, e)
327
  return False
328
 
329
 
330
+ def delete_feedback(feedback_id: str) -> bool:
331
+ """Delete a post-shipment fit-feedback row by ID, sweeping its photo
332
+ object from storage too (best-effort — the row delete is authoritative)."""
333
+ client = _get_client()
334
+ if client is None:
335
+ return False
336
+ try:
337
+ resp = client.table("feedback").select("photo_url").eq("id", feedback_id).execute()
338
+ rows = resp.data or []
339
+ client.table("feedback").delete().eq("id", feedback_id).execute()
340
+ _remove_storage_objects([r.get("photo_url") for r in rows])
341
+ return True
342
+ except Exception as e:
343
+ logger.error("Failed to delete feedback %s: %s", feedback_id, e)
344
+ return False
345
+
346
+
347
  GT_ALLOWED_FIELDS = {"gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_best_finger", "gt_notes"}
348
 
349
 
web_demo/templates/admin.html CHANGED
@@ -272,6 +272,8 @@
272
  <div class="toolbar">
273
  <a href="/">Back to Demo</a>
274
  <button id="fbRefreshBtn">Refresh</button>
 
 
275
  <span class="count" id="fbCountLabel">Loading...</span>
276
  </div>
277
 
@@ -288,10 +290,11 @@
288
  <th>Hand</th>
289
  <th>Photo</th>
290
  <th>Notes</th>
 
291
  </tr>
292
  </thead>
293
  <tbody id="fbTableBody">
294
- <tr><td colspan="9" class="empty">Loading...</td></tr>
295
  </tbody>
296
  </table>
297
  </div>
@@ -312,6 +315,13 @@
312
  const PAGE_SIZE = 25;
313
  let allRows = [];
314
  let currentPage = 1;
 
 
 
 
 
 
 
315
 
316
  let adminToken = sessionStorage.getItem("admin_token") || "";
317
 
@@ -416,7 +426,7 @@
416
  } else {
417
  const start = (currentPage - 1) * PAGE_SIZE;
418
  const slice = allRows.slice(start, start + PAGE_SIZE);
419
- countLabel.textContent = `${total} records · page ${currentPage}/${totalPages}`;
420
  tbody.innerHTML = slice.map(rowHtml).join("");
421
  }
422
  prevPageBtn.disabled = currentPage <= 1;
@@ -433,6 +443,8 @@
433
  loginError.textContent = "Session expired. Please log in again.";
434
  return;
435
  }
 
 
436
  allRows = await resp.json();
437
  currentPage = 1;
438
  renderPage();
@@ -452,6 +464,7 @@
452
  const result = await resp.json();
453
  if (result.success) {
454
  allRows = allRows.filter((r) => r.id !== id);
 
455
  renderPage();
456
  } else {
457
  alert("Delete failed: " + (result.error || "unknown"));
@@ -482,6 +495,12 @@
482
  // ------------------------------------------------------------------
483
  const fbTableBody = document.getElementById("fbTableBody");
484
  const fbCountLabel = document.getElementById("fbCountLabel");
 
 
 
 
 
 
485
 
486
  // Map the stored value codes back to human labels (mirrors the
487
  // /feedback form's <option> text).
@@ -501,7 +520,7 @@
501
  const photoThumb = r.photo_url
502
  ? `<img class="thumb" loading="lazy" src="${esc(r.photo_url)}" onclick="window.open('${esc(r.photo_url)}')" />`
503
  : "-";
504
- return `<tr>
505
  <td><strong>${esc(r.kol_email) || "-"}</strong></td>
506
  <td>${fmtDate(r.submitted_at)}</td>
507
  <td>${esc(r.received_size) || "-"}</td>
@@ -511,9 +530,28 @@
511
  <td>${fbLabel(FB_HAND, r.hand)}</td>
512
  <td>${photoThumb}</td>
513
  <td>${fmtComment(r.notes)}</td>
 
514
  </tr>`;
515
  };
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  const loadFeedback = async () => {
518
  try {
519
  const resp = await fetch(`/api/admin/feedback?token=${encodeURIComponent(adminToken)}`);
@@ -524,19 +562,39 @@
524
  loginError.textContent = "Session expired. Please log in again.";
525
  return;
526
  }
527
- const rows = await resp.json();
528
- if (!rows.length) {
529
- fbCountLabel.textContent = "0 feedback rows";
530
- fbTableBody.innerHTML = '<tr><td colspan="9" class="empty">No feedback yet</td></tr>';
531
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  }
533
- fbCountLabel.textContent = `${rows.length} feedback rows`;
534
- fbTableBody.innerHTML = rows.map(fbRowHtml).join("");
535
  } catch (e) {
536
- fbTableBody.innerHTML = `<tr><td colspan="9" class="empty">Error loading feedback: ${e.message}</td></tr>`;
537
  }
538
  };
539
 
 
 
540
  document.getElementById("fbRefreshBtn").addEventListener("click", loadFeedback);
541
 
542
  // ------------------------------------------------------------------
 
272
  <div class="toolbar">
273
  <a href="/">Back to Demo</a>
274
  <button id="fbRefreshBtn">Refresh</button>
275
+ <button id="fbPrevPageBtn" disabled>&lsaquo; Prev</button>
276
+ <button id="fbNextPageBtn" disabled>Next &rsaquo;</button>
277
  <span class="count" id="fbCountLabel">Loading...</span>
278
  </div>
279
 
 
290
  <th>Hand</th>
291
  <th>Photo</th>
292
  <th>Notes</th>
293
+ <th></th>
294
  </tr>
295
  </thead>
296
  <tbody id="fbTableBody">
297
+ <tr><td colspan="10" class="empty">Loading...</td></tr>
298
  </tbody>
299
  </table>
300
  </div>
 
315
  const PAGE_SIZE = 25;
316
  let allRows = [];
317
  let currentPage = 1;
318
+ let serverTotal = null; // full DB count from X-Total-Count (may exceed loaded rows)
319
+
320
+ // "N rows" or "newest N of M rows" when the server-side fetch was capped.
321
+ const countText = (loaded, total, noun) =>
322
+ (total != null && total > loaded)
323
+ ? `newest ${loaded} of ${total} ${noun}`
324
+ : `${loaded} ${noun}`;
325
 
326
  let adminToken = sessionStorage.getItem("admin_token") || "";
327
 
 
426
  } else {
427
  const start = (currentPage - 1) * PAGE_SIZE;
428
  const slice = allRows.slice(start, start + PAGE_SIZE);
429
+ countLabel.textContent = `${countText(total, serverTotal, "records")} · page ${currentPage}/${totalPages}`;
430
  tbody.innerHTML = slice.map(rowHtml).join("");
431
  }
432
  prevPageBtn.disabled = currentPage <= 1;
 
443
  loginError.textContent = "Session expired. Please log in again.";
444
  return;
445
  }
446
+ serverTotal = parseInt(resp.headers.get("X-Total-Count"), 10);
447
+ if (isNaN(serverTotal)) serverTotal = null;
448
  allRows = await resp.json();
449
  currentPage = 1;
450
  renderPage();
 
464
  const result = await resp.json();
465
  if (result.success) {
466
  allRows = allRows.filter((r) => r.id !== id);
467
+ if (serverTotal != null) serverTotal--;
468
  renderPage();
469
  } else {
470
  alert("Delete failed: " + (result.error || "unknown"));
 
495
  // ------------------------------------------------------------------
496
  const fbTableBody = document.getElementById("fbTableBody");
497
  const fbCountLabel = document.getElementById("fbCountLabel");
498
+ const fbPrevPageBtn = document.getElementById("fbPrevPageBtn");
499
+ const fbNextPageBtn = document.getElementById("fbNextPageBtn");
500
+
501
+ let fbAllRows = [];
502
+ let fbCurrentPage = 1;
503
+ let fbServerTotal = null; // full DB count from X-Total-Count
504
 
505
  // Map the stored value codes back to human labels (mirrors the
506
  // /feedback form's <option> text).
 
520
  const photoThumb = r.photo_url
521
  ? `<img class="thumb" loading="lazy" src="${esc(r.photo_url)}" onclick="window.open('${esc(r.photo_url)}')" />`
522
  : "-";
523
+ return `<tr data-id="${esc(r.id)}">
524
  <td><strong>${esc(r.kol_email) || "-"}</strong></td>
525
  <td>${fmtDate(r.submitted_at)}</td>
526
  <td>${esc(r.received_size) || "-"}</td>
 
530
  <td>${fbLabel(FB_HAND, r.hand)}</td>
531
  <td>${photoThumb}</td>
532
  <td>${fmtComment(r.notes)}</td>
533
+ <td><button class="del-btn" onclick="deleteFbRow(this)">Delete</button></td>
534
  </tr>`;
535
  };
536
 
537
+ const renderFbPage = () => {
538
+ const total = fbAllRows.length;
539
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
540
+ if (fbCurrentPage > totalPages) fbCurrentPage = totalPages;
541
+ if (fbCurrentPage < 1) fbCurrentPage = 1;
542
+ if (total === 0) {
543
+ fbCountLabel.textContent = "0 feedback rows";
544
+ fbTableBody.innerHTML = '<tr><td colspan="10" class="empty">No feedback yet</td></tr>';
545
+ } else {
546
+ const start = (fbCurrentPage - 1) * PAGE_SIZE;
547
+ const slice = fbAllRows.slice(start, start + PAGE_SIZE);
548
+ fbCountLabel.textContent = `${countText(total, fbServerTotal, "feedback rows")} · page ${fbCurrentPage}/${totalPages}`;
549
+ fbTableBody.innerHTML = slice.map(fbRowHtml).join("");
550
+ }
551
+ fbPrevPageBtn.disabled = fbCurrentPage <= 1;
552
+ fbNextPageBtn.disabled = fbCurrentPage >= totalPages;
553
+ };
554
+
555
  const loadFeedback = async () => {
556
  try {
557
  const resp = await fetch(`/api/admin/feedback?token=${encodeURIComponent(adminToken)}`);
 
562
  loginError.textContent = "Session expired. Please log in again.";
563
  return;
564
  }
565
+ fbServerTotal = parseInt(resp.headers.get("X-Total-Count"), 10);
566
+ if (isNaN(fbServerTotal)) fbServerTotal = null;
567
+ fbAllRows = await resp.json();
568
+ fbCurrentPage = 1;
569
+ renderFbPage();
570
+ } catch (e) {
571
+ fbTableBody.innerHTML = `<tr><td colspan="10" class="empty">Error loading feedback: ${e.message}</td></tr>`;
572
+ }
573
+ };
574
+
575
+ window.deleteFbRow = async (btn) => {
576
+ if (!confirm("Delete this feedback record?")) return;
577
+ const tr = btn.closest("tr");
578
+ const id = tr.dataset.id;
579
+ try {
580
+ const resp = await fetch(`/api/admin/feedback/${id}?token=${encodeURIComponent(adminToken)}`, {
581
+ method: "DELETE",
582
+ });
583
+ const result = await resp.json();
584
+ if (result.success) {
585
+ fbAllRows = fbAllRows.filter((r) => r.id !== id);
586
+ if (fbServerTotal != null) fbServerTotal--;
587
+ renderFbPage();
588
+ } else {
589
+ alert("Delete failed: " + (result.error || "unknown"));
590
  }
 
 
591
  } catch (e) {
592
+ alert("Network error: " + e.message);
593
  }
594
  };
595
 
596
+ fbPrevPageBtn.addEventListener("click", () => { fbCurrentPage--; renderFbPage(); });
597
+ fbNextPageBtn.addEventListener("click", () => { fbCurrentPage++; renderFbPage(); });
598
  document.getElementById("fbRefreshBtn").addEventListener("click", loadFeedback);
599
 
600
  // ------------------------------------------------------------------