1
0

Phase 5: reporting, exports, pronouns, and roster freshness

Instructor participation report: per-student histograms, outcome mix
by class day, and a sortable table including the fairness ratio
(answered calls over questions present for, with opt-out days out of
the denominator), plus CSV exports of students, calls, and opt-outs.

Assessment scales are now per-course data: ordered levels with labels
and points out of 100 (defaults carry the old R grading values), with
calls referencing levels by id so renames follow through to history.
Renaming, re-pointing, reordering, and adding levels are always
allowed; deleting a level in use by recorded calls is blocked.

Pronouns and course term dates come from Canvas custom variable
substitutions, at launch and roster-wide via rlid-scoped NRPS; the
student page notes that names/pronouns are Canvas-sourced. Rosters
can also be refreshed outside launches: a "Sync roster now" button
and a sync-rosters CLI command for an hourly cron job, skipping ended
courses. Alembic now runs SQLite-compatible batch migrations with a
constraint naming convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 17:05:34 -07:00
parent 7845167ec5
commit 7ec1be5dc6
32 changed files with 1274 additions and 44 deletions

View File

@@ -3,6 +3,8 @@ import re
import pytest
from conftest import outcome_actions
TODAY = datetime.date.today().isoformat()
@@ -30,7 +32,7 @@ def test_live_flow_records_outcome(instructor):
call_id = match.group(1)
resp = instructor.post(
f"/instructor/call/{call_id}/outcome",
data={"action": "GOOD"},
data={"action": outcome_actions(page)["GOOD"]},
follow_redirects=True,
)
page = resp.get_data(as_text=True)
@@ -76,12 +78,13 @@ def test_day_edit_saves_outcomes(instructor):
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
ids = re.findall(r'name="status-(\d+)"', page)
assert len(ids) == 2
poor_id = re.search(r'value="(\d+)"[^>]*>POOR</option>', page).group(1)
resp = instructor.post(
f"/instructor/day/{TODAY}",
data={
f"status-{ids[0]}": "answered",
f"assessment-{ids[0]}": "POOR",
f"assessment-{ids[0]}": poor_id,
f"note-{ids[0]}": "rough day",
f"status-{ids[1]}": "pending",
f"delete-{ids[1]}": "on",
@@ -103,11 +106,56 @@ def test_outcome_rejects_bad_action_and_foreign_call(instructor):
)
assert resp.status_code == 400
resp = instructor.post(
"/instructor/call/99999/outcome", data={"action": "GOOD"}
"/instructor/call/99999/outcome", data={"action": "missing"}
)
assert resp.status_code == 404
def test_assessment_scale_editing(instructor):
page = instructor.get("/instructor/settings").get_data(as_text=True)
ids = {
label: lid
for lid, label in re.findall(
r'name="label-(\d+)" value="([^"]+)"', page
)
}
assert set(ids) == {"GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER"}
# Record a call assessed GOOD so that level is in use.
record = instructor.post(
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
).get_data(as_text=True)
call_id = re.search(r"/instructor/call/(\d+)/outcome", record).group(1)
instructor.post(
f"/instructor/call/{call_id}/outcome",
data={"action": f"level-{ids['GOOD']}"},
)
# Rename and re-point the in-use level; delete an unused one; add one.
form = {"selection_mode": "weighted", "weight_factor": "2",
"show_assessments": "on",
"new_label": "HEROIC", "new_points": "110"}
for label, lid in ids.items():
form[f"label-{lid}"] = "EXCELLENT" if label == "GOOD" else label
form[f"points-{lid}"] = "90" if label == "GOOD" else "50"
form[f"position-{lid}"] = "0"
form[f"delete-{ids['POOR']}"] = "on"
form[f"delete-{ids['GOOD']}"] = "on" # in use: must survive
page = instructor.post(
"/instructor/settings", data=form, follow_redirects=True
).get_data(as_text=True)
assert 'value="EXCELLENT"' in page # renamed
assert 'value="90.0"' in page # re-pointed
assert "POOR" not in page # unused level deleted
assert re.search(r"in use \(1\s+call\)", page) # used level kept
assert "HEROIC" not in page # >100 points rejected
# The rename follows through to recorded calls.
day_page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
assert "EXCELLENT" in day_page
def test_settings_roundtrip(instructor):
resp = instructor.post(
"/instructor/settings",