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:
81
tests/test_report_ui.py
Normal file
81
tests/test_report_ui.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import outcome_actions
|
||||
|
||||
TODAY = datetime.date.today().isoformat()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
return dev_client
|
||||
|
||||
|
||||
def record_one_call(client, action="GOOD"):
|
||||
client.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = client.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
|
||||
actions = outcome_actions(page)
|
||||
value = actions.get(action) or actions[action.title()]
|
||||
client.post(f"/instructor/call/{call_id}/outcome", data={"action": value})
|
||||
|
||||
|
||||
def test_report_page_renders(instructor):
|
||||
record_one_call(instructor, "GOOD")
|
||||
record_one_call(instructor, "POOR")
|
||||
record_one_call(instructor, "missing")
|
||||
|
||||
page = instructor.get("/instructor/report").get_data(as_text=True)
|
||||
assert "3 questions asked over 1" in page
|
||||
assert "never called" in page
|
||||
assert 'class="hist"' in page
|
||||
assert "Outcomes by class day" in page
|
||||
|
||||
|
||||
def test_report_sorting(instructor):
|
||||
record_one_call(instructor, "GOOD")
|
||||
page = instructor.get("/instructor/report?sort=answered").get_data(as_text=True)
|
||||
rows = re.findall(r"<td>([A-Z][a-z]+ [A-Za-z]+)</td>", page)
|
||||
# The called student sorts first under the answered sort.
|
||||
called = re.search(r"never called", page)
|
||||
assert rows, "expected student rows"
|
||||
assert called
|
||||
|
||||
|
||||
def test_csv_exports(instructor):
|
||||
record_one_call(instructor, "GOOD")
|
||||
resp = instructor.get("/instructor/export/students.csv")
|
||||
assert resp.mimetype == "text/csv"
|
||||
lines = resp.get_data(as_text=True).strip().splitlines()
|
||||
assert lines[0].startswith("name,")
|
||||
assert len(lines) == 9 # header + 8 students
|
||||
|
||||
resp = instructor.get("/instructor/export/calls.csv")
|
||||
lines = resp.get_data(as_text=True).strip().splitlines()
|
||||
assert len(lines) == 2
|
||||
assert ",answered,GOOD," in lines[1]
|
||||
|
||||
resp = instructor.get("/instructor/export/optouts.csv")
|
||||
assert resp.get_data(as_text=True).strip().splitlines()[0].startswith("date,")
|
||||
|
||||
|
||||
def test_report_requires_instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
dev_client.post("/dev/launch/dev-student-1")
|
||||
assert dev_client.get("/instructor/report").status_code == 403
|
||||
assert dev_client.get("/instructor/export/calls.csv").status_code == 403
|
||||
|
||||
|
||||
def test_pronouns_flow_to_pages(instructor):
|
||||
# Dev roster carries pronouns; they should reach the print list.
|
||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "30"})
|
||||
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
|
||||
assert "she/her" in page or "he/him" in page
|
||||
|
||||
instructor.post("/dev/launch/dev-student-1")
|
||||
page = instructor.get("/me").get_data(as_text=True)
|
||||
assert "Ada Lovelace (she/her)" in page
|
||||
assert "drawn from Canvas" in page
|
||||
Reference in New Issue
Block a user