tttjjj commited on
Commit
eb0abff
·
1 Parent(s): 62f8380

Store each review as a separate file under reviews/<submission>/

Browse files

- Submissions are now immutable; reviews live in their own files, so
concurrent reviews by different people never conflict (no read-modify-write).
- add_review() writes reviews/<base>/<stamp>__<reviewer>.json.
- list_submissions() derives current status from the latest review and bundles
the full review timeline + submission record per item (single repo listing).
- Admin page reads bundled data; shows timeline + 'Add a review' form.
- README documents the new submissions/ + reviews/ layout and Python loader.

Files changed (3) hide show
  1. README.md +43 -14
  2. lib/storage.py +131 -144
  3. pages/1_Admin.py +25 -33
README.md CHANGED
@@ -23,7 +23,7 @@ A Streamlit intake form for trial statisticians. Submissions are saved to a **Hu
23
  - `extraction_only` → 1 rubric: `output.json`
24
  - `derivation_required` → 4 rubrics: `output.json` × {Inputs used, Calculated value, Method} + `output.R` × {Reproducibility}
25
  - Each rubric collects `points`, `tolerance`, `criterion`.
26
- - **Admin page (`pages/1_Admin.py`)** — password-gated review console. A submission can be reviewed many times by different people: each review (status + reviewer name + comment) is appended to the submission's `review_history`, and the page shows the full timeline. The top-level `status` always reflects the most recent review. Every review is committed back to the dataset.
27
 
28
  ## Run locally
29
 
@@ -85,9 +85,20 @@ The Space will restart automatically and pick up the new secrets.
85
  ### 6. Test
86
 
87
  - Open the Space URL → fill the form → **Submit**. A new file lands in `submissions/<trial_id>__<username>__<timestamp>.json` in the dataset repo.
88
- - Open the **Admin** page (left sidebar) → enter password → see the submission with status `pending` → change status, add reviewer/note save.
89
 
90
- ## Submission record shape
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  ```json
93
  {
@@ -95,14 +106,6 @@ The Space will restart automatically and pick up the new secrets.
95
  "submittedAt": "2026-06-01T...",
96
  "trial_id": "NCT0001",
97
  "username": "jdoe",
98
- "status": "needs_fix",
99
- "reviewer": "Dr. Lee",
100
- "reviewerNote": "still missing the power assumption",
101
- "reviewedAt": "2026-06-01T16:00:00+00:00",
102
- "review_history": [
103
- {"at": "2026-06-01T15:30:00+00:00", "reviewer": "Dr. Smith", "status": "reviewed", "note": "looks good"},
104
- {"at": "2026-06-01T16:00:00+00:00", "reviewer": "Dr. Lee", "status": "needs_fix", "note": "still missing the power assumption"}
105
- ],
106
  "comparison": {
107
  "trial_id": "NCT0001",
108
  "username": "jdoe",
@@ -125,14 +128,40 @@ The Space will restart automatically and pick up the new secrets.
125
  }
126
  ```
127
 
128
- Load all submissions in Python:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  ```python
131
  from huggingface_hub import snapshot_download
132
- import json, glob
133
 
134
  local = snapshot_download("ttt-77/tdb-intake-submissions", repo_type="dataset")
135
- records = [json.load(open(f)) for f in glob.glob(f"{local}/submissions/*.json")]
 
 
 
 
 
 
 
 
 
 
 
136
  ```
137
 
138
  ## Project structure
 
23
  - `extraction_only` → 1 rubric: `output.json`
24
  - `derivation_required` → 4 rubrics: `output.json` × {Inputs used, Calculated value, Method} + `output.R` × {Reproducibility}
25
  - Each rubric collects `points`, `tolerance`, `criterion`.
26
+ - **Admin page (`pages/1_Admin.py`)** — password-gated review console. A submission can be reviewed many times by different people: each review (status + reviewer name + comment) is written as its own file under `reviews/<submission>/`, and the page shows the full timeline. The current status is the most recent review's status. Submissions themselves are never modified.
27
 
28
  ## Run locally
29
 
 
85
  ### 6. Test
86
 
87
  - Open the Space URL → fill the form → **Submit**. A new file lands in `submissions/<trial_id>__<username>__<timestamp>.json` in the dataset repo.
88
+ - Open the **Admin** page (left sidebar) → enter password → see the submission with status `pending` → add a review (your name + status + comment). It appears in the review timeline and a new file lands under `reviews/<submission>/`. Add more reviews to build up the history.
89
 
90
+ ## Dataset layout
91
+
92
+ Submissions are **immutable**. Each review is a **separate file** — so a
93
+ submission can be reviewed many times by different people, and concurrent
94
+ reviews never conflict (each is a brand-new file, never an overwrite).
95
+
96
+ ```text
97
+ submissions/<trial>__<user>__<stamp>.json # the submission (never rewritten)
98
+ reviews/<trial>__<user>__<stamp>/<stamp>__<rev>.json # one file per review
99
+ ```
100
+
101
+ ### Submission file (`submissions/*.json`)
102
 
103
  ```json
104
  {
 
106
  "submittedAt": "2026-06-01T...",
107
  "trial_id": "NCT0001",
108
  "username": "jdoe",
 
 
 
 
 
 
 
 
109
  "comparison": {
110
  "trial_id": "NCT0001",
111
  "username": "jdoe",
 
128
  }
129
  ```
130
 
131
+ ### Review file (`reviews/<submission>/*.json`)
132
+
133
+ ```json
134
+ {
135
+ "submissionId": "submissions/NCT0001__jdoe__2026-06-01T...Z.json",
136
+ "at": "2026-06-01T16:00:00+00:00",
137
+ "reviewer": "Dr. Lee",
138
+ "status": "needs_fix",
139
+ "note": "still missing the power assumption"
140
+ }
141
+ ```
142
+
143
+ The **current status** of a submission is derived as the most recent review's
144
+ status (or `pending` if it has no reviews yet).
145
+
146
+ ### Load everything in Python
147
 
148
  ```python
149
  from huggingface_hub import snapshot_download
150
+ import json, glob, os
151
 
152
  local = snapshot_download("ttt-77/tdb-intake-submissions", repo_type="dataset")
153
+
154
+ submissions = {
155
+ os.path.basename(f)[:-5]: json.load(open(f))
156
+ for f in glob.glob(f"{local}/submissions/*.json")
157
+ }
158
+ # reviews grouped by submission base name
159
+ reviews = {}
160
+ for f in glob.glob(f"{local}/reviews/*/*.json"):
161
+ base = os.path.basename(os.path.dirname(f))
162
+ reviews.setdefault(base, []).append(json.load(open(f)))
163
+ for base in reviews:
164
+ reviews[base].sort(key=lambda r: r["at"]) # oldest first
165
  ```
166
 
167
  ## Project structure
lib/storage.py CHANGED
@@ -1,13 +1,22 @@
1
  """Storage backend: Hugging Face Dataset repo, with local filesystem fallback for dev.
2
 
 
 
 
 
 
 
 
 
 
3
  Env vars (set in HF Space → Settings → Variables and secrets):
4
  HF_TOKEN - HF user access token with Write permission
5
  HF_DATASET_REPO - e.g. "ttt-77/tdb-intake-submissions"
6
  HF_DATASET_BRANCH - optional, defaults to "main"
7
- ADMIN_PASSWORD - shared password for the /Admin page
8
 
9
- If HF_TOKEN or HF_DATASET_REPO is missing, all I/O goes to ./data/submissions/*.json
10
- so local dev works without HF credentials.
11
  """
12
 
13
  from __future__ import annotations
@@ -35,6 +44,7 @@ _api = HfApi(token=HF_TOKEN) if hf_configured else None
35
 
36
  LOCAL_DATA_DIR = Path("data")
37
  SUBMISSIONS_PREFIX = "submissions"
 
38
 
39
 
40
  def _safe(s: str) -> str:
@@ -45,11 +55,17 @@ def _now_iso() -> str:
45
  return datetime.now(timezone.utc).isoformat()
46
 
47
 
48
- def _stamp_for_filename() -> str:
49
- return _now_iso().replace(":", "-").replace(".", "-")
 
50
 
 
 
 
 
51
 
52
- # ---- HF helpers ----------------------------------------------------------
 
53
 
54
  def _hf_upload_json(path_in_repo: str, payload: Dict[str, Any], commit_message: str) -> None:
55
  assert _api is not None
@@ -65,182 +81,153 @@ def _hf_upload_json(path_in_repo: str, payload: Dict[str, Any], commit_message:
65
 
66
 
67
  def _hf_read_json(path_in_repo: str) -> Optional[Dict[str, Any]]:
68
- """Fetch via the resolve URL — no cache, always fresh."""
69
  url = (
70
  f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
71
  f"/resolve/{HF_DATASET_BRANCH}/{path_in_repo}"
72
  )
73
- headers = {"Authorization": f"Bearer {HF_TOKEN}"}
74
- r = requests.get(url, headers=headers, timeout=20)
75
  if r.status_code == 404:
76
  return None
77
  r.raise_for_status()
78
  return r.json()
79
 
80
 
81
- def _hf_list_submissions() -> List[str]:
82
- assert _api is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  try:
84
- files = _api.list_repo_files(
85
- repo_id=HF_DATASET_REPO,
86
- repo_type="dataset",
87
- revision=HF_DATASET_BRANCH,
88
- )
89
- except HfHubHTTPError as e:
90
- if e.response is not None and e.response.status_code == 404:
91
- return []
92
- raise
93
- return [
94
- f for f in files
95
- if f.startswith(f"{SUBMISSIONS_PREFIX}/") and f.endswith(".json")
96
- ]
97
 
98
 
99
- # ---- Public API ----------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  def create_submission(trial_id: str, username: str, comparison: Dict[str, Any]) -> Dict[str, Any]:
102
- """Write a new submission. Returns a result dict with submissionId and (optionally) url."""
103
- file_name = f"{_safe(trial_id)}__{_safe(username)}__{_stamp_for_filename()}.json"
104
  submission_id = f"{SUBMISSIONS_PREFIX}/{file_name}"
105
  record = {
106
  "submissionId": submission_id,
107
  "submittedAt": _now_iso(),
108
  "trial_id": trial_id,
109
  "username": username,
110
- # These top-level fields mirror the most recent review for easy
111
- # filtering/sorting; they are updated by add_review().
112
- "status": "pending",
113
- "reviewer": "",
114
- "reviewerNote": "",
115
- "reviewedAt": "",
116
- # Full append-only log: one entry per review by any reviewer.
117
- "review_history": [],
118
  "comparison": comparison,
119
  }
 
 
 
 
 
 
 
 
120
 
121
- if hf_configured:
122
- _hf_upload_json(
123
- submission_id,
124
- record,
125
- commit_message=f"Add submission: {trial_id} — {username}",
126
- )
127
- return {
128
- "submissionId": submission_id,
129
- "url": (
130
- f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
131
- f"/blob/{HF_DATASET_BRANCH}/{submission_id}"
132
- ),
133
- "record": record,
134
- }
135
-
136
- # local fs fallback
137
- path = LOCAL_DATA_DIR / submission_id
138
- path.parent.mkdir(parents=True, exist_ok=True)
139
- path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
140
- return {"submissionId": submission_id, "url": None, "record": record}
141
 
 
 
142
 
143
- def list_submissions() -> List[Dict[str, Any]]:
144
- """Return summaries (small fields only) of every submission."""
145
- if hf_configured:
146
- records = []
147
- for path in _hf_list_submissions():
148
- data = _hf_read_json(path)
149
- if not data:
150
- continue
151
- records.append(_summarize(data))
152
- records.sort(key=lambda r: r.get("submittedAt", ""), reverse=True)
153
- return records
154
-
155
- dir_ = LOCAL_DATA_DIR / SUBMISSIONS_PREFIX
156
- if not dir_.exists():
157
- return []
158
- summaries = []
159
- for f in sorted(dir_.glob("*.json"), reverse=True):
160
- try:
161
- data = json.loads(f.read_text(encoding="utf-8"))
162
- summaries.append(_summarize(data))
163
- except Exception:
164
- continue
165
- return summaries
 
 
 
166
 
167
 
168
  def get_submission(submission_id: str) -> Optional[Dict[str, Any]]:
169
  if not submission_id.startswith(f"{SUBMISSIONS_PREFIX}/"):
170
  return None
171
- if hf_configured:
172
- return _hf_read_json(submission_id)
173
- path = LOCAL_DATA_DIR / submission_id
174
- if not path.exists():
175
- return None
176
- return json.loads(path.read_text(encoding="utf-8"))
177
 
178
 
179
- def add_review(
180
- submission_id: str,
181
- status: str,
182
- reviewer: str,
183
- note: str = "",
184
- ) -> Optional[Dict[str, Any]]:
185
- """Append a review to the submission's history.
186
 
187
- A submission can be reviewed many times by different people. Each call adds
188
- one entry to ``review_history`` and mirrors it into the top-level
189
- status/reviewer/reviewerNote/reviewedAt fields (which always reflect the
190
- latest review).
191
  """
192
- record = get_submission(submission_id)
193
- if record is None:
194
- return None
195
-
196
- now = _now_iso()
197
- entry = {
198
- "at": now,
199
- "reviewer": reviewer,
200
- "status": status,
201
- "note": note,
202
- }
203
- history = record.get("review_history")
204
- if not isinstance(history, list):
205
- history = []
206
- history.append(entry)
207
- record["review_history"] = history
208
-
209
- # Mirror the latest review into the top-level fields.
210
- record["status"] = status
211
- record["reviewer"] = reviewer
212
- record["reviewerNote"] = note
213
- record["reviewedAt"] = now
214
-
215
- if hf_configured:
216
- _hf_upload_json(
217
- submission_id,
218
- record,
219
- commit_message=f"Review ({status}) by {reviewer or 'anon'}: "
220
- f"{submission_id.rsplit('/', 1)[-1]}",
221
  )
222
- else:
223
- path = LOCAL_DATA_DIR / submission_id
224
- path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
225
- return record
226
-
227
-
228
- def _summarize(record: Dict[str, Any]) -> Dict[str, Any]:
229
- history = record.get("review_history")
230
- review_count = len(history) if isinstance(history, list) else 0
231
- return {
232
- "submissionId": record.get("submissionId", ""),
233
- "trial_id": record.get("trial_id", ""),
234
- "username": record.get("username", ""),
235
- "submittedAt": record.get("submittedAt", ""),
236
- "status": record.get("status", "pending"),
237
- "reviewedAt": record.get("reviewedAt", ""),
238
- "reviewer": record.get("reviewer", ""),
239
- "review_count": review_count,
240
- }
241
 
242
 
243
- # ---- Admin gate ----------------------------------------------------------
244
 
245
  def check_admin_password(supplied: str) -> bool:
246
  if not ADMIN_PASSWORD:
 
1
  """Storage backend: Hugging Face Dataset repo, with local filesystem fallback for dev.
2
 
3
+ Layout inside the dataset repo:
4
+
5
+ submissions/<trial>__<user>__<stamp>.json (immutable: the submission)
6
+ reviews/<trial>__<user>__<stamp>/<stamp>__<rev>.json (one file per review)
7
+
8
+ Submissions are never rewritten. Each review is a brand-new file, so multiple
9
+ reviewers can review the same submission concurrently with no write conflict.
10
+ The "current status" of a submission is derived from its most recent review.
11
+
12
  Env vars (set in HF Space → Settings → Variables and secrets):
13
  HF_TOKEN - HF user access token with Write permission
14
  HF_DATASET_REPO - e.g. "ttt-77/tdb-intake-submissions"
15
  HF_DATASET_BRANCH - optional, defaults to "main"
16
+ ADMIN_PASSWORD - shared password for the Admin page
17
 
18
+ If HF_TOKEN or HF_DATASET_REPO is missing, all I/O goes to ./data/... so local
19
+ dev works without HF credentials.
20
  """
21
 
22
  from __future__ import annotations
 
44
 
45
  LOCAL_DATA_DIR = Path("data")
46
  SUBMISSIONS_PREFIX = "submissions"
47
+ REVIEWS_PREFIX = "reviews"
48
 
49
 
50
  def _safe(s: str) -> str:
 
55
  return datetime.now(timezone.utc).isoformat()
56
 
57
 
58
+ def _stamp(iso: Optional[str] = None) -> str:
59
+ return (iso or _now_iso()).replace(":", "-").replace(".", "-")
60
+
61
 
62
+ def _base_id(submission_id: str) -> str:
63
+ """'submissions/foo.json' -> 'foo'"""
64
+ name = submission_id.split("/")[-1]
65
+ return name[:-5] if name.endswith(".json") else name
66
 
67
+
68
+ # ---- low-level read/write/list (HF or local) -----------------------------
69
 
70
  def _hf_upload_json(path_in_repo: str, payload: Dict[str, Any], commit_message: str) -> None:
71
  assert _api is not None
 
81
 
82
 
83
  def _hf_read_json(path_in_repo: str) -> Optional[Dict[str, Any]]:
 
84
  url = (
85
  f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
86
  f"/resolve/{HF_DATASET_BRANCH}/{path_in_repo}"
87
  )
88
+ r = requests.get(url, headers={"Authorization": f"Bearer {HF_TOKEN}"}, timeout=20)
 
89
  if r.status_code == 404:
90
  return None
91
  r.raise_for_status()
92
  return r.json()
93
 
94
 
95
+ def _write_json(path_in_repo: str, payload: Dict[str, Any], commit_message: str) -> None:
96
+ if hf_configured:
97
+ _hf_upload_json(path_in_repo, payload, commit_message)
98
+ return
99
+ p = LOCAL_DATA_DIR / path_in_repo
100
+ p.parent.mkdir(parents=True, exist_ok=True)
101
+ p.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
102
+
103
+
104
+ def _read_json(path_in_repo: str) -> Optional[Dict[str, Any]]:
105
+ if hf_configured:
106
+ return _hf_read_json(path_in_repo)
107
+ p = LOCAL_DATA_DIR / path_in_repo
108
+ if not p.exists():
109
+ return None
110
  try:
111
+ return json.loads(p.read_text(encoding="utf-8"))
112
+ except Exception:
113
+ return None
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
+ def _all_files() -> List[str]:
117
+ """List every file path in the repo (HF) or under ./data (local)."""
118
+ if hf_configured:
119
+ assert _api is not None
120
+ try:
121
+ return _api.list_repo_files(
122
+ repo_id=HF_DATASET_REPO,
123
+ repo_type="dataset",
124
+ revision=HF_DATASET_BRANCH,
125
+ )
126
+ except HfHubHTTPError as e:
127
+ if e.response is not None and e.response.status_code == 404:
128
+ return []
129
+ raise
130
+ if not LOCAL_DATA_DIR.exists():
131
+ return []
132
+ return [p.relative_to(LOCAL_DATA_DIR).as_posix() for p in LOCAL_DATA_DIR.rglob("*.json")]
133
+
134
+
135
+ # ---- public API ----------------------------------------------------------
136
 
137
  def create_submission(trial_id: str, username: str, comparison: Dict[str, Any]) -> Dict[str, Any]:
138
+ """Write a new (immutable) submission file. Returns submissionId + url."""
139
+ file_name = f"{_safe(trial_id)}__{_safe(username)}__{_stamp()}.json"
140
  submission_id = f"{SUBMISSIONS_PREFIX}/{file_name}"
141
  record = {
142
  "submissionId": submission_id,
143
  "submittedAt": _now_iso(),
144
  "trial_id": trial_id,
145
  "username": username,
 
 
 
 
 
 
 
 
146
  "comparison": comparison,
147
  }
148
+ _write_json(submission_id, record, f"Add submission: {trial_id} — {username}")
149
+ url = (
150
+ f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
151
+ f"/blob/{HF_DATASET_BRANCH}/{submission_id}"
152
+ if hf_configured
153
+ else None
154
+ )
155
+ return {"submissionId": submission_id, "url": url, "record": record}
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
+ def add_review(submission_id: str, status: str, reviewer: str, note: str = "") -> Dict[str, Any]:
159
+ """Append a review as its own file under reviews/<base>/.
160
 
161
+ Each review is a new file (never overwrites), so concurrent reviews by
162
+ different people cannot conflict.
163
+ """
164
+ base = _base_id(submission_id)
165
+ now = _now_iso()
166
+ review = {
167
+ "submissionId": submission_id,
168
+ "at": now,
169
+ "reviewer": reviewer,
170
+ "status": status,
171
+ "note": note,
172
+ }
173
+ review_path = f"{REVIEWS_PREFIX}/{base}/{_stamp(now)}__{_safe(reviewer) or 'anon'}.json"
174
+ _write_json(review_path, review, f"Review ({status}) by {reviewer or 'anon'} on {base}")
175
+ return review
176
+
177
+
178
+ def list_reviews(submission_id: str, all_files: Optional[List[str]] = None) -> List[Dict[str, Any]]:
179
+ """All reviews for a submission, oldest first."""
180
+ base = _base_id(submission_id)
181
+ prefix = f"{REVIEWS_PREFIX}/{base}/"
182
+ files = all_files if all_files is not None else _all_files()
183
+ paths = sorted(f for f in files if f.startswith(prefix) and f.endswith(".json"))
184
+ reviews = [r for r in (_read_json(p) for p in paths) if r]
185
+ reviews.sort(key=lambda r: r.get("at", ""))
186
+ return reviews
187
 
188
 
189
  def get_submission(submission_id: str) -> Optional[Dict[str, Any]]:
190
  if not submission_id.startswith(f"{SUBMISSIONS_PREFIX}/"):
191
  return None
192
+ return _read_json(submission_id)
 
 
 
 
 
193
 
194
 
195
+ def list_submissions() -> List[Dict[str, Any]]:
196
+ """Every submission with its derived status and full review timeline.
 
 
 
 
 
197
 
198
+ Each item: submissionId, trial_id, username, submittedAt, status,
199
+ reviewedAt, reviewer, review_count, reviews (list), submission (full record).
 
 
200
  """
201
+ files = _all_files()
202
+ sub_paths = sorted(
203
+ f for f in files if f.startswith(f"{SUBMISSIONS_PREFIX}/") and f.endswith(".json")
204
+ )
205
+ result: List[Dict[str, Any]] = []
206
+ for sp in sub_paths:
207
+ sub = _read_json(sp)
208
+ if not sub:
209
+ continue
210
+ reviews = list_reviews(sp, all_files=files)
211
+ latest = reviews[-1] if reviews else None
212
+ result.append(
213
+ {
214
+ "submissionId": sp,
215
+ "trial_id": sub.get("trial_id", ""),
216
+ "username": sub.get("username", ""),
217
+ "submittedAt": sub.get("submittedAt", ""),
218
+ "status": latest["status"] if latest else "pending",
219
+ "reviewedAt": latest["at"] if latest else "",
220
+ "reviewer": latest["reviewer"] if latest else "",
221
+ "review_count": len(reviews),
222
+ "reviews": reviews,
223
+ "submission": sub,
224
+ }
 
 
 
 
 
225
  )
226
+ result.sort(key=lambda r: r.get("submittedAt", ""), reverse=True)
227
+ return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
 
230
+ # ---- admin gate ----------------------------------------------------------
231
 
232
  def check_admin_password(supplied: str) -> bool:
233
  if not ADMIN_PASSWORD:
pages/1_Admin.py CHANGED
@@ -1,8 +1,8 @@
1
- """Admin review console — list submissions, view review history, add reviews.
2
 
3
- A submission can be reviewed many times by different people. Each review is
4
- appended to the submission's ``review_history``; the most recent one is mirrored
5
- into the top-level status fields for filtering/sorting.
6
  """
7
 
8
  from __future__ import annotations
@@ -16,7 +16,6 @@ from lib.storage import (
16
  ADMIN_PASSWORD,
17
  add_review,
18
  check_admin_password,
19
- get_submission,
20
  hf_configured,
21
  list_submissions,
22
  )
@@ -59,7 +58,7 @@ if not st.session_state.admin_authed:
59
  # ------------- load list -------------------------------------------------
60
 
61
  if not hf_configured:
62
- st.info("ℹ️ Reading from `./data/submissions/` (local dev mode).")
63
 
64
  cols = st.columns([3, 1])
65
  with cols[0]:
@@ -74,23 +73,23 @@ with cols[1]:
74
  st.rerun()
75
 
76
  try:
77
- summaries = list_submissions()
78
  except Exception as e:
79
  st.error(f"Failed to list submissions: {e}")
80
  st.stop()
81
 
82
  if status_filter:
83
- summaries = [s for s in summaries if s["status"] in status_filter]
84
 
85
- st.caption(f"{len(summaries)} submission(s)")
86
 
87
- if not summaries:
88
  st.info("No submissions match this filter.")
89
  st.stop()
90
 
91
  # ------------- list display ----------------------------------------------
92
 
93
- for s in summaries:
94
  n_reviews = s.get("review_count", 0)
95
  review_tag = f" · 💬 {n_reviews}" if n_reviews else " · no reviews yet"
96
  label = (
@@ -99,32 +98,26 @@ for s in summaries:
99
  f"_{s['submittedAt']}_{review_tag}"
100
  )
101
  with st.expander(label):
102
- record = get_submission(s["submissionId"])
103
- if record is None:
104
- st.error("Could not fetch this record.")
105
- continue
106
-
107
  meta_c1, meta_c2 = st.columns(2)
108
  with meta_c1:
109
  st.markdown(
110
- f"**Current status:** {status_badge(record.get('status', 'pending'))} \n"
111
- f"**Submitted:** {record.get('submittedAt', '')} \n"
112
- f"**Last reviewed:** {record.get('reviewedAt', '') or '—'}"
113
  )
114
  with meta_c2:
115
  st.markdown(
116
- f"**File:** `{record.get('submissionId', '')}` \n"
117
- f"**Last reviewer:** {record.get('reviewer', '') or '—'}"
118
  )
119
 
120
- # ---- Review history ------------------------------------------
121
- history = record.get("review_history") or []
122
- st.markdown(f"#### Review history ({len(history)})")
123
- if not history:
124
  st.caption("No reviews yet.")
125
  else:
126
- # Newest first.
127
- for rev in reversed(history):
128
  st.markdown(
129
  f"- {status_badge(rev.get('status', ''))} — "
130
  f"**{rev.get('reviewer') or 'anon'}** "
@@ -132,14 +125,14 @@ for s in summaries:
132
  + (f" \n {rev.get('note')}" if rev.get("note") else "")
133
  )
134
 
135
- # ---- Add a new review ----------------------------------------
136
  st.markdown("#### Add a review")
137
  with st.form(f"review_{s['submissionId']}"):
138
  new_status = st.radio(
139
  "Status",
140
  options=VALID_STATUSES,
141
- index=VALID_STATUSES.index(record.get("status", "pending"))
142
- if record.get("status") in VALID_STATUSES
143
  else 0,
144
  horizontal=True,
145
  )
@@ -148,8 +141,7 @@ for s in summaries:
148
  reviewer = st.text_input("Your name", placeholder="e.g., Dr. Smith")
149
  with rc2:
150
  note = st.text_input("Comment", placeholder="optional")
151
- submitted = st.form_submit_button("Add review", type="primary")
152
- if submitted:
153
  if not reviewer.strip():
154
  st.error("Please enter your name.")
155
  else:
@@ -166,4 +158,4 @@ for s in summaries:
166
  st.error(f"Failed to add review: {e}")
167
 
168
  with st.expander("Raw submission JSON"):
169
- st.code(json.dumps(record, indent=2, ensure_ascii=False), language="json")
 
1
+ """Admin review console — list submissions, view review timeline, add reviews.
2
 
3
+ Submissions are immutable. Each review is stored as its own file under
4
+ ``reviews/<submission>/``; a submission can be reviewed many times by different
5
+ people. The "current status" shown here is the most recent review's status.
6
  """
7
 
8
  from __future__ import annotations
 
16
  ADMIN_PASSWORD,
17
  add_review,
18
  check_admin_password,
 
19
  hf_configured,
20
  list_submissions,
21
  )
 
58
  # ------------- load list -------------------------------------------------
59
 
60
  if not hf_configured:
61
+ st.info("ℹ️ Reading from `./data/` (local dev mode).")
62
 
63
  cols = st.columns([3, 1])
64
  with cols[0]:
 
73
  st.rerun()
74
 
75
  try:
76
+ items = list_submissions()
77
  except Exception as e:
78
  st.error(f"Failed to list submissions: {e}")
79
  st.stop()
80
 
81
  if status_filter:
82
+ items = [s for s in items if s["status"] in status_filter]
83
 
84
+ st.caption(f"{len(items)} submission(s)")
85
 
86
+ if not items:
87
  st.info("No submissions match this filter.")
88
  st.stop()
89
 
90
  # ------------- list display ----------------------------------------------
91
 
92
+ for s in items:
93
  n_reviews = s.get("review_count", 0)
94
  review_tag = f" · 💬 {n_reviews}" if n_reviews else " · no reviews yet"
95
  label = (
 
98
  f"_{s['submittedAt']}_{review_tag}"
99
  )
100
  with st.expander(label):
 
 
 
 
 
101
  meta_c1, meta_c2 = st.columns(2)
102
  with meta_c1:
103
  st.markdown(
104
+ f"**Current status:** {status_badge(s.get('status', 'pending'))} \n"
105
+ f"**Submitted:** {s.get('submittedAt', '')} \n"
106
+ f"**Last reviewed:** {s.get('reviewedAt', '') or '—'}"
107
  )
108
  with meta_c2:
109
  st.markdown(
110
+ f"**File:** `{s.get('submissionId', '')}` \n"
111
+ f"**Last reviewer:** {s.get('reviewer', '') or '—'}"
112
  )
113
 
114
+ # ---- Review timeline -----------------------------------------
115
+ reviews = s.get("reviews") or []
116
+ st.markdown(f"#### Review history ({len(reviews)})")
117
+ if not reviews:
118
  st.caption("No reviews yet.")
119
  else:
120
+ for rev in reversed(reviews): # newest first
 
121
  st.markdown(
122
  f"- {status_badge(rev.get('status', ''))} — "
123
  f"**{rev.get('reviewer') or 'anon'}** "
 
125
  + (f" \n {rev.get('note')}" if rev.get("note") else "")
126
  )
127
 
128
+ # ---- Add a review --------------------------------------------
129
  st.markdown("#### Add a review")
130
  with st.form(f"review_{s['submissionId']}"):
131
  new_status = st.radio(
132
  "Status",
133
  options=VALID_STATUSES,
134
+ index=VALID_STATUSES.index(s.get("status", "pending"))
135
+ if s.get("status") in VALID_STATUSES
136
  else 0,
137
  horizontal=True,
138
  )
 
141
  reviewer = st.text_input("Your name", placeholder="e.g., Dr. Smith")
142
  with rc2:
143
  note = st.text_input("Comment", placeholder="optional")
144
+ if st.form_submit_button("Add review", type="primary"):
 
145
  if not reviewer.strip():
146
  st.error("Please enter your name.")
147
  else:
 
158
  st.error(f"Failed to add review: {e}")
159
 
160
  with st.expander("Raw submission JSON"):
161
+ st.code(json.dumps(s.get("submission", {}), indent=2, ensure_ascii=False), language="json")