Spaces:
Sleeping
Sleeping
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.
- README.md +16 -8
- app.py +60 -16
- 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 |
-
- **
|
|
|
|
| 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
|
| 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 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
|
| 96 |
```text
|
| 97 |
-
submissions/<trial>__<user>
|
| 98 |
-
reviews/<trial>__<user>
|
| 99 |
```
|
| 100 |
|
|
|
|
|
|
|
|
|
|
| 101 |
### Submission file (`submissions/*.json`)
|
| 102 |
|
| 103 |
```json
|
| 104 |
{
|
| 105 |
-
"submissionId": "submissions/
|
|
|
|
|
|
|
| 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)` pair — submitting 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
|
| 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 =
|
|
|
|
| 83 |
st.session_state.last_result = {
|
| 84 |
"kind": "success",
|
| 85 |
-
"msg": f"
|
|
|
|
| 86 |
"url": result.get("url"),
|
| 87 |
}
|
| 88 |
-
#
|
| 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
|
| 138 |
-
"""
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
record = {
|
| 142 |
"submissionId": submission_id,
|
| 143 |
-
"
|
|
|
|
|
|
|
|
|
|
| 144 |
"trial_id": trial_id,
|
| 145 |
"username": username,
|
| 146 |
"comparison": comparison,
|
| 147 |
}
|
| 148 |
-
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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 |
|