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>
96 lines
3.1 KiB
Python
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
|