1
0
Files
coldcall_lti/tests/test_reports.py
Benjamin Mako Hill 7ec1be5dc6 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>
2026-07-31 17:05:34 -07:00

96 lines
3.1 KiB
Python

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