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:
@@ -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
30
tests/test_cli.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
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
|
||||
95
tests/test_reports.py
Normal file
95
tests/test_reports.py
Normal 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
|
||||
@@ -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")])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user