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

@@ -32,3 +32,17 @@ def make_app(tmp_path, dev_mode=True):
@pytest.fixture
def dev_client(tmp_path):
return make_app(tmp_path, dev_mode=True).test_client()
def outcome_actions(page):
"""Map outcome button labels to their form values on a live page,
e.g. {'GOOD': 'level-1', ..., 'Missing': 'missing'}."""
import re
return {
label.strip(): value
for value, label in re.findall(
r'<button type="submit" name="action" value="([^"]+)">([^<]+)</button>',
page,
)
}

30
tests/test_cli.py Normal file
View File

@@ -0,0 +1,30 @@
import datetime
from sqlalchemy import select
from coldcall_lti.models import Course
from conftest import make_app
def test_sync_rosters_skips_unsyncable_and_ended_courses(tmp_path):
app = make_app(tmp_path, dev_mode=True)
client = app.test_client()
client.post("/dev/launch/dev-instructor") # creates the dev course
with app.app_context():
db = app.extensions["db_session_factory"]()
ended = Course(
lti_context_id="old-course",
title="Old Course",
nrps_url="https://example.edu/nrps",
end_date=datetime.date.today() - datetime.timedelta(days=90),
)
db.add(ended)
db.commit()
db.close()
result = app.test_cli_runner().invoke(args=["sync-rosters"])
assert result.exit_code == 0
# The dev course has no NRPS service; the old course has ended.
assert "no roster service recorded, skipped" in result.output
assert "Old Course: ended, skipped" in result.output

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",

View File

@@ -28,6 +28,26 @@ def test_extract_launch_info_instructor():
assert info.is_instructor
def test_extract_launch_info_pronouns_custom_claim():
data = sample_launch_data()
data[launch.CLAIM_CUSTOM] = {"pronouns": "ze/zir"}
assert launch.extract_launch_info(data).pronouns == "ze/zir"
data[launch.CLAIM_CUSTOM] = {"pronouns": "$com.instructure.Person.pronouns"}
assert launch.extract_launch_info(data).pronouns is None
def test_extract_launch_info_course_dates():
data = sample_launch_data()
data[launch.CLAIM_CUSTOM] = {
"course_start": "2026-09-30T07:00:00Z",
"course_end": "$Canvas.course.endAt",
}
info = launch.extract_launch_info(data)
assert info.course_start == launch.datetime.date(2026, 9, 30)
assert info.course_end is None
def test_upsert_launch_creates_and_is_idempotent(db_session):
info = launch.extract_launch_info(sample_launch_data())
course, student = launch.upsert_launch(db_session, info)

81
tests/test_report_ui.py Normal file
View 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

95
tests/test_reports.py Normal file
View File

@@ -0,0 +1,95 @@
import datetime
from coldcall_lti import models, reports
D1 = datetime.date(2026, 10, 1)
D2 = datetime.date(2026, 10, 3)
def setup_course(db):
course = models.Course(lti_context_id="ctx-1")
db.add(course)
db.flush()
models.ensure_default_levels(db, course)
students = []
for i in range(3):
s = models.Student(canvas_user_id=f"u{i}", name=f"Student {i}")
db.add(s)
students.append(s)
db.flush()
for s in students:
db.add(models.Enrollment(course_id=course.id, student_id=s.id))
db.flush()
return course, students
def add_call(db, course, student, day, status, assessment=None):
level_ids = {
lvl.label: lvl.id
for lvl in models.assessment_levels(db, course.id)
}
db.add(
models.Call(
course_id=course.id,
student_id=student.id,
session_date=day,
status=status,
assessment_id=level_ids[assessment] if assessment else None,
)
)
db.flush()
def test_student_stats_fairness_denominator(db_session):
course, (a, b, c) = setup_course(db_session)
# Day 1: three questions; day 2: one question. b opted out day 1.
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "GOOD")
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "POOR")
add_call(db_session, course, c, D1, models.STATUS_MISSING)
add_call(db_session, course, b, D2, models.STATUS_ANSWERED, "GOOD")
# Skipped and pending calls count nowhere.
add_call(db_session, course, c, D2, models.STATUS_SKIPPED)
add_call(db_session, course, c, D2, models.STATUS_PENDING)
db_session.add(
models.OptOut(course_id=course.id, student_id=b.id, date=D1)
)
db_session.flush()
rows = {r["student"].id: r for r in reports.student_stats(db_session, course)}
assert rows[a.id]["answered"] == 2
assert rows[a.id]["questions_present"] == 4
assert rows[a.id]["prop_asked"] == 0.5
# b missed day 1's three questions by opting out.
assert rows[b.id]["questions_present"] == 1
assert rows[b.id]["prop_asked"] == 1.0
assert rows[b.id]["optouts"] == 1
assert rows[c.id]["answered"] == 0
assert rows[c.id]["missing"] == 1
assert rows[c.id]["prop_asked"] == 0.0
def test_assessments_by_day(db_session):
course, (a, b, c) = setup_course(db_session)
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "GOOD")
add_call(db_session, course, b, D1, models.STATUS_ANSWERED, "GOOD")
add_call(db_session, course, c, D1, models.STATUS_MISSING)
add_call(db_session, course, a, D2, models.STATUS_ANSWERED, "SATISFACTORY")
by_day = reports.assessments_by_day(db_session, course.id)
assert [d["day"] for d in by_day] == [D1, D2]
assert by_day[0]["counts"] == {"GOOD": 2, "missing": 1}
assert by_day[0]["total"] == 3
assert by_day[1]["counts"] == {"SATISFACTORY": 1}
def test_histogram_includes_zero_bin():
h = reports.histogram([0, 0, 2])
assert h["bins"] == [
{"value": 0, "students": 2},
{"value": 1, "students": 0},
{"value": 2, "students": 1},
]
assert h["max_students"] == 2

View File

@@ -74,6 +74,28 @@ def test_sync_skips_inactive_members(db_session):
assert db_session.query(models.Student).count() == 1
def test_sync_extracts_pronouns_from_nrps_message(db_session):
course = make_course(db_session)
custom_claim = "https://purl.imsglobal.org/spec/lti/claim/custom"
sync_roster(
db_session,
course,
[
member("u1", message=[{custom_claim: {"pronouns": "they/them"}}]),
# Unexpanded variable (platform without pronouns) is ignored.
member(
"u2",
message=[{custom_claim: {"pronouns": "$com.instructure.Person.pronouns"}}],
),
],
)
students = {
s.canvas_user_id: s for s in db_session.query(models.Student).all()
}
assert students["u1"].pronouns == "they/them"
assert students["u2"].pronouns is None
def test_sync_updates_changed_names(db_session):
course = make_course(db_session)
sync_roster(db_session, course, [member("u1", "Old Name")])

View File

@@ -3,6 +3,8 @@ import re
import pytest
from conftest import outcome_actions
TODAY = datetime.date.today()
@@ -97,7 +99,8 @@ def test_assessment_visibility_setting(student):
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
target = "Ada Lovelace" in page
student.post(
f"/instructor/call/{call_id}/outcome", data={"action": "POOR"}
f"/instructor/call/{call_id}/outcome",
data={"action": outcome_actions(page)["POOR"]},
)
if target:
break