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:
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
|
||||
Reference in New Issue
Block a user