tttjjj commited on
Commit
089c7d1
·
1 Parent(s): 58d2e52

Make submissions editable; load by trial_id + username

Browse files

- One submission file per (trial_id, username): submitting again updates the
same file (upsert) instead of creating a new timestamped one.
- save_submission() preserves createdAt, sets updatedAt on each save.
- Add get_submission_by_key() / submission_id_for(); form gets a 'Load existing
submission' button that pulls a prior submission back in for editing.
- Submit no longer clears the form, so editing can continue.
- Question widgets keyed by a form_nonce bumped on load, so loaded values
actually populate the fields (Streamlit widget-state gotcha).
- Admin list sorts by updatedAt and exposes it.

Files changed (3) hide show
  1. README.md +16 -8
  2. app.py +60 -16
  3. lib/storage.py +40 -8
README.md CHANGED
@@ -23,7 +23,8 @@ 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 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
 
@@ -84,25 +85,32 @@ The Space will restart automatically and pick up the new secrets.
84
 
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
  {
105
- "submissionId": "submissions/NCT0001__jdoe__2026-06-01T...Z.json",
 
 
106
  "submittedAt": "2026-06-01T...",
107
  "trial_id": "NCT0001",
108
  "username": "jdoe",
 
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
+ - **Load existing submission** — re-enter the same `trial_id` + `username` and click Load to pull a previous submission back into the form, edit it, and Submit again to update.
27
+ - **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.
28
 
29
  ## Run locally
30
 
 
85
 
86
  ### 6. Test
87
 
88
+ - Open the Space URL → fill the form → **Submit**. A file lands in `submissions/<trial_id>__<username>.json` in the dataset repo. Submitting again with the same trial_id + username updates that file.
89
  - 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.
90
 
91
  ## Dataset layout
92
 
93
+ One submission file per `(trial_id, username)` pairsubmitting again
94
+ **updates** the same file, so a submission can be loaded back and edited.
95
+ (Edit history is preserved in the dataset's git commits.) Each review is a
96
+ **separate file**, so a submission can be reviewed many times by different
97
+ people and concurrent reviews never conflict.
98
 
99
  ```text
100
+ submissions/<trial>__<user>.json # the submission (upserted on each submit)
101
+ reviews/<trial>__<user>/<stamp>__<rev>.json # one file per review
102
  ```
103
 
104
+ To edit an existing submission: on the form, enter the same `trial_id` +
105
+ `username` and click **Load existing submission**, edit, then **Submit**.
106
+
107
  ### Submission file (`submissions/*.json`)
108
 
109
  ```json
110
  {
111
+ "submissionId": "submissions/NCT0001__jdoe.json",
112
+ "createdAt": "2026-06-01T...",
113
+ "updatedAt": "2026-06-04T...",
114
  "submittedAt": "2026-06-01T...",
115
  "trial_id": "NCT0001",
116
  "username": "jdoe",
app.py CHANGED
@@ -22,7 +22,7 @@ from lib.schema import (
22
  next_question_id,
23
  rubrics_for_type,
24
  )
25
- from lib.storage import create_submission, hf_configured
26
 
27
  st.set_page_config(
28
  page_title="TDB Intake",
@@ -40,6 +40,10 @@ if "username" not in st.session_state:
40
  st.session_state.username = ""
41
  if "last_result" not in st.session_state:
42
  st.session_state.last_result = None
 
 
 
 
43
 
44
 
45
  # ------------- callbacks -------------------------------------------------
@@ -66,6 +70,39 @@ def _save_draft() -> None:
66
  st.session_state.last_result = {"kind": "draft", "msg": "Draft saved in this browser session."}
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def _submit() -> None:
70
  trial_id = st.session_state.trial_id.strip()
71
  username = st.session_state.username.strip()
@@ -79,16 +116,15 @@ def _submit() -> None:
79
  "prompts": st.session_state.questions,
80
  }
81
  try:
82
- result = create_submission(trial_id, username, comparison)
 
83
  st.session_state.last_result = {
84
  "kind": "success",
85
- "msg": f"Submitted: `{result['submissionId']}`",
 
86
  "url": result.get("url"),
87
  }
88
- # Reset form
89
- st.session_state.questions = []
90
- st.session_state.trial_id = ""
91
- st.session_state.username = ""
92
  except Exception as e:
93
  st.session_state.last_result = {"kind": "error", "msg": f"Submit failed: {e}"}
94
 
@@ -112,6 +148,12 @@ with c1:
112
  with c2:
113
  st.text_input("username", key="username", placeholder="e.g., jdoe")
114
 
 
 
 
 
 
 
115
  st.divider()
116
 
117
  # ------------- questions list --------------------------------------------
@@ -121,6 +163,8 @@ st.subheader("Questions")
121
  if not st.session_state.questions:
122
  st.caption('No questions yet. Click "Add question" below to begin.')
123
 
 
 
124
  for i, q in enumerate(st.session_state.questions):
125
  with st.container(border=True):
126
  head_l, head_r = st.columns([6, 1])
@@ -128,12 +172,12 @@ for i, q in enumerate(st.session_state.questions):
128
  new_id = st.text_input(
129
  "id",
130
  value=q["id"],
131
- key=f"q_{i}_id",
132
  label_visibility="collapsed",
133
  )
134
  q["id"] = new_id
135
  with head_r:
136
- st.button("Remove", key=f"rm_{i}", on_click=_remove_question, args=(i,))
137
 
138
  col1, col2 = st.columns(2)
139
  with col1:
@@ -143,7 +187,7 @@ for i, q in enumerate(st.session_state.questions):
143
  "design_element",
144
  options=de_options,
145
  index=de_idx,
146
- key=f"q_{i}_de",
147
  format_func=lambda x: "— select —" if x == "" else x,
148
  )
149
  q["design_element"] = new_de
@@ -151,7 +195,7 @@ for i, q in enumerate(st.session_state.questions):
151
  q["design_element_other"] = st.text_input(
152
  "Specify other design element",
153
  value=q.get("design_element_other", ""),
154
- key=f"q_{i}_de_other",
155
  )
156
  else:
157
  q["design_element_other"] = ""
@@ -163,7 +207,7 @@ for i, q in enumerate(st.session_state.questions):
163
  "question_type",
164
  options=qt_options,
165
  index=qt_idx,
166
- key=f"q_{i}_qt",
167
  format_func=lambda x: "— select —" if x == "" else x,
168
  )
169
  # If question_type changed, regenerate rubrics via callback-like pattern.
@@ -173,7 +217,7 @@ for i, q in enumerate(st.session_state.questions):
173
  new_question = st.text_input(
174
  "question",
175
  value=q["question"],
176
- key=f"q_{i}_question",
177
  placeholder="e.g., Alpha allocated to PFS",
178
  )
179
  q["question"] = new_question
@@ -192,18 +236,18 @@ for i, q in enumerate(st.session_state.questions):
192
  r["points"] = st.text_input(
193
  "points",
194
  value=r["points"],
195
- key=f"q_{i}_r_{j}_points",
196
  )
197
  with rc2:
198
  r["tolerance"] = st.text_input(
199
  "tolerance",
200
  value=r["tolerance"],
201
- key=f"q_{i}_r_{j}_tolerance",
202
  )
203
  r["criterion"] = st.text_area(
204
  "criterion",
205
  value=r["criterion"],
206
- key=f"q_{i}_r_{j}_criterion",
207
  height=80,
208
  )
209
 
 
22
  next_question_id,
23
  rubrics_for_type,
24
  )
25
+ from lib.storage import get_submission_by_key, hf_configured, save_submission
26
 
27
  st.set_page_config(
28
  page_title="TDB Intake",
 
40
  st.session_state.username = ""
41
  if "last_result" not in st.session_state:
42
  st.session_state.last_result = None
43
+ # Bumped whenever we replace the questions wholesale (load / reset) so the
44
+ # per-question widgets get fresh keys and actually show the new values.
45
+ if "form_nonce" not in st.session_state:
46
+ st.session_state.form_nonce = 0
47
 
48
 
49
  # ------------- callbacks -------------------------------------------------
 
70
  st.session_state.last_result = {"kind": "draft", "msg": "Draft saved in this browser session."}
71
 
72
 
73
+ def _load() -> None:
74
+ """Load an existing submission by (trial_id, username) into the form."""
75
+ trial_id = st.session_state.trial_id.strip()
76
+ username = st.session_state.username.strip()
77
+ if not trial_id or not username:
78
+ st.session_state.last_result = {
79
+ "kind": "error",
80
+ "msg": "Enter trial_id and username, then click Load.",
81
+ }
82
+ return
83
+ try:
84
+ record = get_submission_by_key(trial_id, username)
85
+ except Exception as e:
86
+ st.session_state.last_result = {"kind": "error", "msg": f"Load failed: {e}"}
87
+ return
88
+ if not record:
89
+ st.session_state.last_result = {
90
+ "kind": "info",
91
+ "msg": f"No existing submission for `{trial_id}` / `{username}`. "
92
+ "Add questions and Submit to create one.",
93
+ }
94
+ return
95
+ prompts = (record.get("comparison") or {}).get("prompts") or []
96
+ st.session_state.questions = prompts
97
+ st.session_state.form_nonce += 1 # force question widgets to refresh
98
+ updated = record.get("updatedAt") or record.get("submittedAt") or ""
99
+ st.session_state.last_result = {
100
+ "kind": "success",
101
+ "msg": f"Loaded {len(prompts)} question(s) (last updated {updated}). "
102
+ "Edit and Submit to update.",
103
+ }
104
+
105
+
106
  def _submit() -> None:
107
  trial_id = st.session_state.trial_id.strip()
108
  username = st.session_state.username.strip()
 
116
  "prompts": st.session_state.questions,
117
  }
118
  try:
119
+ result = save_submission(trial_id, username, comparison)
120
+ verb = "Updated" if result.get("updated") else "Submitted"
121
  st.session_state.last_result = {
122
  "kind": "success",
123
+ "msg": f"{verb}: `{result['submissionId']}`. "
124
+ "You can keep editing and Submit again to update.",
125
  "url": result.get("url"),
126
  }
127
+ # Keep the form populated so the user can continue editing.
 
 
 
128
  except Exception as e:
129
  st.session_state.last_result = {"kind": "error", "msg": f"Submit failed: {e}"}
130
 
 
148
  with c2:
149
  st.text_input("username", key="username", placeholder="e.g., jdoe")
150
 
151
+ st.button(
152
+ "Load existing submission",
153
+ on_click=_load,
154
+ help="If you already submitted for this trial_id + username, load it back to edit.",
155
+ )
156
+
157
  st.divider()
158
 
159
  # ------------- questions list --------------------------------------------
 
163
  if not st.session_state.questions:
164
  st.caption('No questions yet. Click "Add question" below to begin.')
165
 
166
+ n = st.session_state.form_nonce # widget-key namespace; changes on load/reset
167
+
168
  for i, q in enumerate(st.session_state.questions):
169
  with st.container(border=True):
170
  head_l, head_r = st.columns([6, 1])
 
172
  new_id = st.text_input(
173
  "id",
174
  value=q["id"],
175
+ key=f"q_{n}_{i}_id",
176
  label_visibility="collapsed",
177
  )
178
  q["id"] = new_id
179
  with head_r:
180
+ st.button("Remove", key=f"rm_{n}_{i}", on_click=_remove_question, args=(i,))
181
 
182
  col1, col2 = st.columns(2)
183
  with col1:
 
187
  "design_element",
188
  options=de_options,
189
  index=de_idx,
190
+ key=f"q_{n}_{i}_de",
191
  format_func=lambda x: "— select —" if x == "" else x,
192
  )
193
  q["design_element"] = new_de
 
195
  q["design_element_other"] = st.text_input(
196
  "Specify other design element",
197
  value=q.get("design_element_other", ""),
198
+ key=f"q_{n}_{i}_de_other",
199
  )
200
  else:
201
  q["design_element_other"] = ""
 
207
  "question_type",
208
  options=qt_options,
209
  index=qt_idx,
210
+ key=f"q_{n}_{i}_qt",
211
  format_func=lambda x: "— select —" if x == "" else x,
212
  )
213
  # If question_type changed, regenerate rubrics via callback-like pattern.
 
217
  new_question = st.text_input(
218
  "question",
219
  value=q["question"],
220
+ key=f"q_{n}_{i}_question",
221
  placeholder="e.g., Alpha allocated to PFS",
222
  )
223
  q["question"] = new_question
 
236
  r["points"] = st.text_input(
237
  "points",
238
  value=r["points"],
239
+ key=f"q_{n}_{i}_r_{j}_points",
240
  )
241
  with rc2:
242
  r["tolerance"] = st.text_input(
243
  "tolerance",
244
  value=r["tolerance"],
245
+ key=f"q_{n}_{i}_r_{j}_tolerance",
246
  )
247
  r["criterion"] = st.text_area(
248
  "criterion",
249
  value=r["criterion"],
250
+ key=f"q_{n}_{i}_r_{j}_criterion",
251
  height=80,
252
  )
253
 
lib/storage.py CHANGED
@@ -134,25 +134,56 @@ def _all_files() -> List[str]:
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]:
@@ -215,6 +246,7 @@ def list_submissions() -> List[Dict[str, Any]]:
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 "",
@@ -223,7 +255,7 @@ def list_submissions() -> List[Dict[str, Any]]:
223
  "submission": sub,
224
  }
225
  )
226
- result.sort(key=lambda r: r.get("submittedAt", ""), reverse=True)
227
  return result
228
 
229
 
 
134
 
135
  # ---- public API ----------------------------------------------------------
136
 
137
+ def submission_id_for(trial_id: str, username: str) -> str:
138
+ """Stable submission id (path) for a (trial_id, username) pair.
139
+
140
+ One submission per pair — submitting again updates the same file, so a
141
+ submission can be loaded back and edited.
142
+ """
143
+ return f"{SUBMISSIONS_PREFIX}/{_safe(trial_id)}__{_safe(username)}.json"
144
+
145
+
146
+ def get_submission_by_key(trial_id: str, username: str) -> Optional[Dict[str, Any]]:
147
+ """Load an existing submission by (trial_id, username), or None."""
148
+ return get_submission(submission_id_for(trial_id, username))
149
+
150
+
151
+ def save_submission(trial_id: str, username: str, comparison: Dict[str, Any]) -> Dict[str, Any]:
152
+ """Create or update the submission for (trial_id, username).
153
+
154
+ If a submission already exists for this pair, it is updated in place
155
+ (createdAt is preserved); otherwise a new one is created.
156
+ """
157
+ submission_id = submission_id_for(trial_id, username)
158
+ now = _now_iso()
159
+ existing = get_submission(submission_id)
160
+ created_at = (existing or {}).get("createdAt") or (existing or {}).get("submittedAt") or now
161
+ is_update = existing is not None
162
+
163
  record = {
164
  "submissionId": submission_id,
165
+ "createdAt": created_at,
166
+ "updatedAt": now,
167
+ # kept for backward compatibility with older records / admin display
168
+ "submittedAt": created_at,
169
  "trial_id": trial_id,
170
  "username": username,
171
  "comparison": comparison,
172
  }
173
+ verb = "Update" if is_update else "Add"
174
+ _write_json(submission_id, record, f"{verb} submission: {trial_id} — {username}")
175
  url = (
176
  f"https://huggingface.co/datasets/{HF_DATASET_REPO}"
177
  f"/blob/{HF_DATASET_BRANCH}/{submission_id}"
178
  if hf_configured
179
  else None
180
  )
181
+ return {
182
+ "submissionId": submission_id,
183
+ "url": url,
184
+ "record": record,
185
+ "updated": is_update,
186
+ }
187
 
188
 
189
  def add_review(submission_id: str, status: str, reviewer: str, note: str = "") -> Dict[str, Any]:
 
246
  "trial_id": sub.get("trial_id", ""),
247
  "username": sub.get("username", ""),
248
  "submittedAt": sub.get("submittedAt", ""),
249
+ "updatedAt": sub.get("updatedAt", sub.get("submittedAt", "")),
250
  "status": latest["status"] if latest else "pending",
251
  "reviewedAt": latest["at"] if latest else "",
252
  "reviewer": latest["reviewer"] if latest else "",
 
255
  "submission": sub,
256
  }
257
  )
258
+ result.sort(key=lambda r: r.get("updatedAt", ""), reverse=True)
259
  return result
260
 
261