From 46f7f556336b96d96b62db1a650100e6a26d75ee Mon Sep 17 00:00:00 2001
From: Benjamin Mako Hill
Date: Fri, 31 Jul 2026 18:38:22 -0700
Subject: [PATCH] Phase 6: participation grading, gradebook passback, opt-out
integrity
Ports the timing-neutral foregone-participation grading scheme from
participation_grades.R: answer quality (per-course level points) minus
a deduction for participation foregone through unavailability,
estimated by Monte Carlo simulation of the actual weighted draw and
averaged over when absences fall, plus a small form-filing incentive
for drawn-while-absent-without-opt-out days. Reason-blind and
luck-protected; zero-answer students floor to 0. Parameters
(allowance in SD units, passing line, form penalty, simulation
size/seed) are course settings. Verified against the R engine's
rendered 2026q2 reports via the new import-legacy command: quality and
availability match exactly, finals within Monte Carlo noise; dropped
students import as inactive enrollments and are excluded identically.
Grades are computed on demand into stored GradeRun snapshots and
reviewed on a grades page with CSV export and per-student reports
(also served to students via a publish toggle). Display scales map
points to UW 4.0, a threshold table (one-click import of the Canvas
course grading scheme), or raw points. Gradebook passback via AGS
sits behind a settings toggle with a review-then-push flow.
Opt-out withdrawals are now soft-deletes with a withdrawn_at audit
trail, and close when class begins (class days gained optional start
times), so availability records cannot be rewritten after the fact.
Co-Authored-By: Claude Fable 5
---
README.md | 47 +++
coldcall_lti/__init__.py | 6 +-
coldcall_lti/cli.py | 197 ++++++++++++-
coldcall_lti/dev.py | 13 +-
coldcall_lti/grades.py | 217 ++++++++++++++
coldcall_lti/grading.py | 270 ++++++++++++++++++
coldcall_lti/instructor.py | 82 +++++-
coldcall_lti/launch.py | 26 ++
coldcall_lti/lti.py | 28 ++
coldcall_lti/models.py | 82 +++++-
coldcall_lti/reports.py | 4 +-
coldcall_lti/scales.py | 42 +++
coldcall_lti/templates/grade_report.html | 53 ++++
coldcall_lti/templates/grades.html | 72 +++++
coldcall_lti/templates/instructor_home.html | 1 +
coldcall_lti/templates/push_preview.html | 23 ++
coldcall_lti/templates/push_result.html | 17 ++
coldcall_lti/templates/schedule.html | 10 +-
coldcall_lti/templates/settings.html | 75 +++++
coldcall_lti/templates/student_home.html | 12 +-
coldcall_lti/views.py | 51 +++-
...lass_start_times_and_optout_withdrawal_.py | 40 +++
...e289_assessment_levels_and_nrps_details.py | 78 -----
...e289_assessment_levels_grading_services.py | 93 ++++++
tests/test_grades_ui.py | 123 ++++++++
tests/test_grading.py | 238 +++++++++++++++
tests/test_student_ui.py | 67 +++++
27 files changed, 1869 insertions(+), 98 deletions(-)
create mode 100644 coldcall_lti/grades.py
create mode 100644 coldcall_lti/grading.py
create mode 100644 coldcall_lti/scales.py
create mode 100644 coldcall_lti/templates/grade_report.html
create mode 100644 coldcall_lti/templates/grades.html
create mode 100644 coldcall_lti/templates/push_preview.html
create mode 100644 coldcall_lti/templates/push_result.html
create mode 100644 migrations/versions/3b6f28fbdc4b_class_start_times_and_optout_withdrawal_.py
delete mode 100644 migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py
create mode 100644 migrations/versions/826e2835e289_assessment_levels_grading_services.py
create mode 100644 tests/test_grades_ui.py
create mode 100644 tests/test_grading.py
diff --git a/README.md b/README.md
index 2edcfbe..d3c4629 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,7 @@ configuration: add a custom parameter
pronouns=$com.instructure.Person.pronouns
course_start=$Canvas.course.startAt
course_end=$Canvas.course.endAt
+grading_scheme=$com.instructure.Course.gradingScheme
```
The course dates bound the schedule and date pickers; the tool works
@@ -115,6 +116,52 @@ fields). Pronouns appear on the live call card, printed call lists,
and each student's own page. If the account has pronouns disabled,
everything simply shows without them.
+## Opt-outs
+
+Students remove themselves from a day's cold-call list by picking the
+date on their page; nothing else is asked. There is deliberately no
+enforced submission deadline: an instructor running live calls in
+class gets up-to-the-second opt-outs automatically, while one who
+prints a call list beforehand should just tell students how much lead
+time they need (for example, "an hour or two before class"), since
+opt-outs after printing won't be on the paper.
+
+Withdrawing an opt-out is bounded, because un-opting-out after the
+fact would rewrite a student's availability record (and with it their
+participation grade): withdrawal closes when class begins, using the
+start time recorded on the schedule page, or at the start of the class
+day when no time is recorded. Withdrawals are also recorded rather
+than deleted — the opt-out export includes a withdrawn_at column, so
+the full history of changes survives.
+
+## Grading
+
+Final participation grades use the timing-neutral foregone-
+participation scheme (a port of the earlier participation_grades.R):
+a student's grade is the quality of their answers minus a deduction
+for participation they missed by being unavailable, estimated by
+Monte Carlo simulation of the actual weighted draw and averaged over
+when the absences fall, plus a small incentive penalty for being
+drawn while absent with no opt-out filed. The reason for an absence
+never matters, and luck of the draw never moves a grade. Parameters
+(allowance, passing line, penalties, simulation size) are per-course
+settings; grades are computed on demand, reviewed on the instructor's
+grades page, and shown to students only when the instructor publishes
+reports. With gradebook passback enabled, a review-then-push page
+sends the reviewed scores to Canvas via the Assignment and Grade
+Services; nothing is ever sent without explicit confirmation.
+
+Grades are computed in points out of 100 and displayed through a
+per-course scale: the built-in linear UW 4.0 map, a threshold table
+(letter grades, importable in one click from the course's own Canvas
+grading scheme), or raw points.
+
+The port was verified against the R engine's rendered reports from a
+real course: answer quality and availability match exactly; the
+simulated penalty agrees within Monte Carlo noise. `flask --app
+coldcall_lti import-legacy ` imports a manual-era class directory
+for this kind of testing.
+
## Developing without Canvas
Because a Developer Key takes institutional approval to get, the app
diff --git a/coldcall_lti/__init__.py b/coldcall_lti/__init__.py
index 54d9483..01a626d 100644
--- a/coldcall_lti/__init__.py
+++ b/coldcall_lti/__init__.py
@@ -1,3 +1,4 @@
+import json
import os
from flask import Flask
@@ -23,11 +24,14 @@ def create_app(config=Config):
app.config["SESSION_COOKIE_SAMESITE"] = "None"
app.config["SESSION_COOKIE_SECURE"] = True
- from . import instructor, lti, views
+ app.template_filter("fromjson")(json.loads)
+
+ from . import grades, instructor, lti, views
app.register_blueprint(lti.bp)
app.register_blueprint(views.bp)
app.register_blueprint(instructor.bp)
+ app.register_blueprint(grades.bp)
if app.config["DEV_MODE"]:
from . import dev
diff --git a/coldcall_lti/cli.py b/coldcall_lti/cli.py
index 8c44657..dced967 100644
--- a/coldcall_lti/cli.py
+++ b/coldcall_lti/cli.py
@@ -6,17 +6,59 @@ launches (enrollment shifts constantly in the first weeks of a term):
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
"""
+import csv
import datetime
+import glob
+import json
+import os
+import re
import click
from flask import current_app
from sqlalchemy import select
-from .models import Course
+from .models import (
+ AssessmentLevel,
+ Call,
+ ClassDay,
+ Course,
+ Enrollment,
+ OptOut,
+ Student,
+ ensure_default_levels,
+)
from .roster import sync_roster
+# Exact point values from participation_grades.R / the older grading
+# scripts (one UW grade step = 50/3.3 points).
+_GPA_STEP = 50 / (4 - 0.7)
+LEGACY_LEVEL_POINTS = {
+ "GOOD": 100.0,
+ "OKAY": 100 - 0.7 * _GPA_STEP,
+ "SATISFACTORY": 100 - _GPA_STEP,
+ "FAIR": 100 - _GPA_STEP,
+ "POOR": 100 - 2 * _GPA_STEP,
+ "BAD": 100 - 2 * _GPA_STEP,
+ "NO MEANINGFUL ANSWER": 0.0,
+}
+
def register_cli(app):
+ @app.cli.command("import-legacy")
+ @click.argument("directory")
+ @click.option("--context-id", default=None,
+ help="LTI context id for the created course")
+ def import_legacy(directory, context_id):
+ """One-off import of a manual-era class directory (call_list
+ TSVs, opt-out TSV, student info TSV) for testing and for
+ verifying the grading port against old outputs."""
+ db = current_app.extensions["db_session_factory"]()
+ try:
+ _import_legacy(db, directory, context_id)
+ db.commit()
+ finally:
+ db.close()
+
@app.cli.command("sync-rosters")
def sync_rosters():
"""Refresh every syncable course roster from its LMS."""
@@ -48,3 +90,156 @@ def register_cli(app):
)
finally:
db.close()
+
+
+def _read_tsv(path):
+ # Tolerate hand-edited files: CRLF line endings and stray blank
+ # lines (splitlines handles \r\n; blank lines are dropped).
+ with open(path, newline="") as f:
+ lines = [l for l in f.read().splitlines() if l.strip()]
+ return list(csv.DictReader(lines, delimiter="\t"))
+
+
+def _import_legacy(db, directory, context_id):
+ with open(os.path.join(directory, "data", "configuration.json")) as f:
+ config = json.load(f)
+ unique_col = config["unique_name_rowname"]
+ preferred_col = config.get("preferred_name_rowname")
+
+ context_id = context_id or f"legacy-{os.path.basename(os.path.abspath(directory))}"
+ course = Course(lti_context_id=context_id, title=f"Imported: {context_id}")
+ db.add(course)
+ db.flush()
+ ensure_default_levels(db, course)
+
+ # Students and enrollments from the student-info sheet: that is the
+ # enrolled set the R pipeline (track_participation.R) used.
+ info = _read_tsv(os.path.join(directory, config["student_info_file"]))
+ students = {}
+ for row in info:
+ uid = row[unique_col].strip()
+ if not uid or uid in students:
+ continue
+ student = Student(
+ canvas_user_id=f"legacy-{context_id}-{uid}",
+ name=(row.get(preferred_col) or "").strip() or uid,
+ )
+ db.add(student)
+ db.flush()
+ db.add(Enrollment(course_id=course.id, student_id=student.id))
+ students[uid] = student
+ db.flush()
+
+ levels = {
+ lvl.label: lvl
+ for lvl in db.execute(
+ select(AssessmentLevel).where(
+ AssessmentLevel.course_id == course.id
+ )
+ ).scalars()
+ }
+
+ def level_for(label):
+ label = label.strip()
+ if not label or label in ("MISSING", "NA"):
+ return None
+ if label not in levels:
+ points = LEGACY_LEVEL_POINTS.get(label)
+ if points is None:
+ click.echo(f"unknown assessment {label!r}: created at 50.0",
+ err=True)
+ points = 50.0
+ lvl = AssessmentLevel(
+ course_id=course.id, label=label, points=round(points, 4),
+ position=len(levels),
+ )
+ db.add(lvl)
+ db.flush()
+ levels[label] = lvl
+ return levels[label]
+
+ # Make sure legacy labels carry the R scripts' exact point values,
+ # even where they collide with seeded defaults.
+ for label, lvl in levels.items():
+ if label in LEGACY_LEVEL_POINTS:
+ lvl.points = round(LEGACY_LEVEL_POINTS[label], 4)
+
+ # Students who appear in call lists but not on the info sheet
+ # dropped the class: imported as inactive enrollments with their
+ # call history kept, exactly what an NRPS sync would produce.
+ dropped = set()
+
+ def dropped_student(uid):
+ dropped.add(uid)
+ student = Student(
+ canvas_user_id=f"legacy-{context_id}-{uid}", name=uid
+ )
+ db.add(student)
+ db.flush()
+ db.add(
+ Enrollment(
+ course_id=course.id, student_id=student.id, active=False
+ )
+ )
+ students[uid] = student
+ return student
+
+ n_calls = 0
+ days = set()
+ for path in sorted(
+ glob.glob(os.path.join(directory, "data", "call_list-*.tsv"))
+ ):
+ day = datetime.date.fromisoformat(
+ re.search(r"call_list-(\d{4}-\d{2}-\d{2})", path).group(1)
+ )
+ for row in _read_tsv(path):
+ uid = row[unique_col].strip()
+ student = students.get(uid) or dropped_student(uid)
+ answered = row["answered"].strip().upper()
+ if answered not in ("TRUE", "FALSE"):
+ continue # never resolved
+ lvl = level_for(row.get("assessment", ""))
+ db.add(
+ Call(
+ course_id=course.id,
+ student_id=student.id,
+ session_date=day,
+ status="answered" if answered == "TRUE" else "missing",
+ assessment_id=lvl.id if (lvl and answered == "TRUE") else None,
+ )
+ )
+ days.add(day)
+ n_calls += 1
+ db.flush()
+
+ for day in sorted(days):
+ db.add(ClassDay(course_id=course.id, date=day))
+
+ n_optouts = 0
+ optout_path = os.path.join(directory, config["optout_file"])
+ if os.path.exists(optout_path):
+ seen = set()
+ for row in _read_tsv(optout_path):
+ uid = row[unique_col].strip()
+ student = students.get(uid)
+ date_str = row.get(
+ "Date of class session you will be absent", ""
+ ).strip()
+ if student is None or not date_str:
+ continue
+ month, day_n, year = (int(x) for x in date_str.split("/"))
+ date = datetime.date(year, month, day_n)
+ if (student.id, date) in seen:
+ continue
+ seen.add((student.id, date))
+ db.add(
+ OptOut(course_id=course.id, student_id=student.id, date=date)
+ )
+ n_optouts += 1
+ db.flush()
+
+ click.echo(
+ f"imported {context_id}: {len(students) - len(dropped)} active "
+ f"students, {len(dropped)} dropped (inactive), {n_calls} calls "
+ f"over {len(days)} days, {n_optouts} opt-outs"
+ )
diff --git a/coldcall_lti/dev.py b/coldcall_lti/dev.py
index 287c70d..37fc242 100644
--- a/coldcall_lti/dev.py
+++ b/coldcall_lti/dev.py
@@ -76,14 +76,21 @@ def _ensure_dev_course(db):
ensure_default_levels(db, course)
sync_roster(db, course, DEV_MEMBERS)
- # A Tue/Thu meeting pattern for the next few weeks, so the opt-out
- # picker and schedule pages have something to show.
+ # A Tue/Thu 23:59 meeting pattern for the next few weeks, so the
+ # opt-out picker and schedule pages have something to show and
+ # same-day withdrawal is exercisable in dev.
if not class_days(db, course.id):
day = datetime.date.today()
for offset in range(28):
d = day + datetime.timedelta(days=offset)
if d.weekday() in (1, 3):
- db.add(ClassDay(course_id=course.id, date=d))
+ db.add(
+ ClassDay(
+ course_id=course.id,
+ date=d,
+ start_time=datetime.time(23, 59),
+ )
+ )
db.commit()
return course
diff --git a/coldcall_lti/grades.py b/coldcall_lti/grades.py
new file mode 100644
index 0000000..50773f1
--- /dev/null
+++ b/coldcall_lti/grades.py
@@ -0,0 +1,217 @@
+import csv
+import datetime
+import io
+import json
+
+from flask import (
+ Blueprint,
+ Response,
+ abort,
+ current_app,
+ redirect,
+ render_template,
+ url_for,
+)
+
+from . import grading, scales
+from .db import get_db
+from .models import Call, Student, latest_grade_run
+from .views import current_course, require_instructor
+
+bp = Blueprint("grades", __name__, url_prefix="/instructor/grades")
+
+
+def _run_results(db, course):
+ run = latest_grade_run(db, course.id)
+ if run is None:
+ return None, None
+ return run, json.loads(run.results)
+
+
+@bp.get("/")
+@require_instructor
+def view():
+ db = get_db()
+ course = current_course()
+ run, results = _run_results(db, course)
+ return render_template(
+ "grades.html",
+ course=course,
+ run=run,
+ results=results,
+ display=lambda pts: scales.display(course, pts),
+ )
+
+
+@bp.post("/compute")
+@require_instructor
+def compute():
+ db = get_db()
+ course = current_course()
+ grading.run_and_store(db, course)
+ db.commit()
+ return redirect(url_for(".view"))
+
+
+@bp.get("/export.csv")
+@require_instructor
+def export():
+ db = get_db()
+ course = current_course()
+ run, results = _run_results(db, course)
+ if not results or "students" not in results:
+ abort(404)
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(
+ ["name", "asked", "days_missed", "q_points", "foregone",
+ "caught", "final_points", "display_grade"]
+ )
+ for r in results["students"]:
+ writer.writerow(
+ [r["name"], r["asked"], r["days_missed"], r["q_points"],
+ r["foregone"], r["caught"], r["final_points"],
+ scales.display(course, r["final_points"])]
+ )
+ return Response(
+ buf.getvalue(),
+ mimetype="text/csv",
+ headers={"Content-Disposition": "attachment; filename=grades.csv"},
+ )
+
+
+def student_report_context(db, course, results, student_id):
+ row = next(
+ (r for r in results["students"] if r["student_id"] == student_id),
+ None,
+ )
+ if row is None:
+ return None
+ student = db.get(Student, student_id)
+ calls = (
+ db.query(Call)
+ .filter(
+ Call.course_id == course.id,
+ Call.student_id == student_id,
+ Call.status.in_(["answered", "missing"]),
+ )
+ .order_by(Call.session_date, Call.id)
+ .all()
+ )
+ return {
+ "course": course,
+ "student": student,
+ "row": row,
+ "calls": calls,
+ "results": results,
+ "final_display": scales.display(course, row["final_points"]),
+ "q_display": scales.display(course, row["q_points"]),
+ }
+
+
+@bp.get("/student/")
+@require_instructor
+def student_report(student_id):
+ db = get_db()
+ course = current_course()
+ run, results = _run_results(db, course)
+ if not results or "students" not in results:
+ abort(404)
+ context = student_report_context(db, course, results, student_id)
+ if context is None:
+ abort(404)
+ return render_template(
+ "grade_report.html", is_instructor_view=True, **context
+ )
+
+
+def _push_rows(db, course, results):
+ students = {
+ s.id: s
+ for s in db.query(Student)
+ .filter(Student.id.in_([r["student_id"] for r in results["students"]]))
+ }
+ rows = []
+ for r in results["students"]:
+ score, maximum = scales.ags_score(course, r["final_points"])
+ rows.append(
+ {
+ "row": r,
+ "student": students[r["student_id"]],
+ "score": score,
+ "maximum": maximum,
+ }
+ )
+ return rows
+
+
+@bp.get("/push")
+@require_instructor
+def push_preview():
+ db = get_db()
+ course = current_course()
+ if not course.ags_enabled:
+ abort(404)
+ run, results = _run_results(db, course)
+ if not results or "students" not in results:
+ abort(404)
+ return render_template(
+ "push_preview.html",
+ course=course,
+ run=run,
+ rows=_push_rows(db, course, results),
+ )
+
+
+@bp.post("/push")
+@require_instructor
+def push():
+ from pylti1p3.grade import Grade
+ from pylti1p3.lineitem import LineItem
+
+ from .lti import ags_service_for_course
+
+ db = get_db()
+ course = current_course()
+ if not course.ags_enabled:
+ abort(404)
+ run, results = _run_results(db, course)
+ if not results or "students" not in results:
+ abort(404)
+ rows = _push_rows(db, course, results)
+
+ service = ags_service_for_course(course)
+ outcomes = []
+ if service is None:
+ if not current_app.config["DEV_MODE"]:
+ abort(400, "No gradebook service recorded for this course.")
+ outcomes = [
+ {"name": r["row"]["name"], "ok": True, "detail": "simulated (dev mode)"}
+ for r in rows
+ ]
+ else:
+ lineitem = LineItem()
+ lineitem.set_tag("coldcall-participation")
+ lineitem.set_label("Case discussion participation")
+ lineitem.set_score_maximum(rows[0]["maximum"] if rows else 100)
+ lineitem = service.find_or_create_lineitem(lineitem)
+ timestamp = datetime.datetime.now(datetime.UTC).isoformat()
+ for r in rows:
+ grade = Grade()
+ grade.set_score_given(r["score"])
+ grade.set_score_maximum(r["maximum"])
+ grade.set_user_id(r["student"].canvas_user_id)
+ grade.set_timestamp(timestamp)
+ grade.set_activity_progress("Completed")
+ grade.set_grading_progress("FullyGraded")
+ try:
+ service.put_grade(grade, lineitem)
+ outcomes.append({"name": r["row"]["name"], "ok": True,
+ "detail": f"{r['score']}"})
+ except Exception as exc:
+ outcomes.append({"name": r["row"]["name"], "ok": False,
+ "detail": str(exc)})
+
+ return render_template(
+ "push_result.html", course=course, outcomes=outcomes
+ )
diff --git a/coldcall_lti/grading.py b/coldcall_lti/grading.py
new file mode 100644
index 0000000..4d21895
--- /dev/null
+++ b/coldcall_lti/grading.py
@@ -0,0 +1,270 @@
+"""Participation grading: the timing-neutral "foregone participation"
+scheme, ported from participation_grades.R.
+
+A student's grade is the quality of their answers (mean points over
+answered calls) minus a deduction for expected participation foregone
+through unavailability, minus a small form-filing incentive. Foregone
+participation is estimated by Monte Carlo simulation of the actual
+weighted cold-call draw, averaged over which days are missed, so the
+grade depends on how much was missed, not when. Realized call counts
+never enter the penalty: luck is never graded.
+
+Everything internal is in points out of 100; display scales map at the
+edge (scales.py).
+"""
+
+import datetime
+import json
+from collections import defaultdict
+
+import numpy as np
+from sqlalchemy import select
+
+from .models import (
+ STATUS_ANSWERED,
+ STATUS_MISSING,
+ Call,
+ Enrollment,
+ GradeRun,
+ OptOut,
+ Student,
+)
+
+
+def gather_inputs(db, course):
+ """Everything the engine needs, from the database."""
+ enrolled = [
+ student
+ for (student,) in db.execute(
+ select(Student)
+ .join(Enrollment, Enrollment.student_id == Student.id)
+ .where(
+ Enrollment.course_id == course.id,
+ Enrollment.role == "student",
+ Enrollment.active.is_(True),
+ )
+ .order_by(Student.sortable_name, Student.name)
+ )
+ ]
+ calls = (
+ db.execute(
+ select(Call)
+ .where(
+ Call.course_id == course.id,
+ Call.status.in_([STATUS_ANSWERED, STATUS_MISSING]),
+ )
+ .order_by(Call.session_date, Call.id)
+ )
+ .scalars()
+ .all()
+ )
+ days = sorted({c.session_date for c in calls})
+ enrolled_ids = {s.id for s in enrolled}
+
+ answered_by_day = defaultdict(int)
+ points = defaultdict(list)
+ missing_days = defaultdict(set)
+ excluded_students = set()
+ excluded_calls = 0
+ for c in calls:
+ if c.student_id not in enrolled_ids:
+ # Dropped students: their calls happened but count nowhere,
+ # matching the R pipeline's non-roster handling.
+ excluded_students.add(c.student_id)
+ excluded_calls += 1
+ continue
+ if c.status == STATUS_ANSWERED:
+ answered_by_day[c.session_date] += 1
+ if c.level is not None:
+ points[c.student_id].append(c.level.points)
+ else:
+ missing_days[c.student_id].add(c.session_date)
+
+ optout_days = defaultdict(set)
+ for o in db.execute(
+ select(OptOut).where(
+ OptOut.course_id == course.id, OptOut.withdrawn_at.is_(None)
+ )
+ ).scalars():
+ optout_days[o.student_id].add(o.date)
+
+ return {
+ "enrolled": enrolled,
+ "days": days,
+ "calls_per_day": [answered_by_day[d] for d in days],
+ "points": dict(points),
+ "missing_days": dict(missing_days),
+ "optout_days": dict(optout_days),
+ "excluded_students": len(excluded_students),
+ "excluded_calls": excluded_calls,
+ }
+
+
+def _simulate_counts(present, focal_present, calls_per_day, weight_factor,
+ n_sims, rng):
+ """How many questions the focal student (index 0) answers, across
+ n_sims simulated terms. `present` is a bool matrix (students x
+ days) for everyone but the focal student's own row, which is taken
+ from `focal_present` (n_sims x days, so each simulation can have a
+ different absence pattern for the focal student)."""
+ n_students, n_days = present.shape
+ weights = np.ones((n_sims, n_students))
+ counts = np.zeros(n_sims, dtype=int)
+
+ for di in range(n_days):
+ k = calls_per_day[di]
+ if k <= 0:
+ continue
+ pool = np.flatnonzero(present[:, di])
+ if pool.size == 0:
+ continue
+ focal_in_pool = pool[0] == 0 if pool.size else False
+ focal_mask = focal_present[:, di]
+ for _ in range(k):
+ w = weights[:, pool]
+ if focal_in_pool:
+ w = w.copy()
+ w[:, 0] = w[:, 0] * focal_mask
+ cw = np.cumsum(w, axis=1)
+ total = cw[:, -1]
+ # Rows with an empty effective pool can't happen: the focal
+ # row only zeroes when others remain (pool includes them).
+ u = rng.random(n_sims) * total
+ idx = (cw < u[:, None]).sum(axis=1)
+ chosen = pool[idx]
+ weights[np.arange(n_sims), chosen] /= weight_factor
+ counts += (chosen == 0) & (
+ focal_mask if focal_in_pool else np.zeros(n_sims, bool)
+ )
+ return counts
+
+
+def compute(db, course):
+ """Run the full grading computation; returns a JSON-able dict."""
+ inputs = gather_inputs(db, course)
+ enrolled = inputs["enrolled"]
+ days = inputs["days"]
+ n = len(enrolled)
+ n_days = len(days)
+ calls_per_day = inputs["calls_per_day"]
+ total_answered = sum(calls_per_day)
+
+ params = {
+ "allowance_sd_units": course.allowance_sd_units,
+ "passing_points": course.passing_points,
+ "form_penalty_points": course.form_penalty_points,
+ "weight_factor": course.weight_factor,
+ "n_sims": course.n_sims,
+ "seed": course.sim_seed,
+ }
+
+ if n == 0 or total_answered == 0:
+ return {
+ "params": params,
+ "error": "No enrolled students or no answered calls yet.",
+ }
+
+ rng = np.random.default_rng(course.sim_seed)
+ n_sims = course.n_sims
+
+ # Availability: reason-blind union of opt-outs and drawn-but-missing
+ # days. Row 0 is the focal slot, overwritten per simulation.
+ unavailable = {
+ s.id: inputs["optout_days"].get(s.id, set())
+ | inputs["missing_days"].get(s.id, set())
+ for s in enrolled
+ }
+ present = np.array(
+ [[d not in unavailable[s.id] for d in days] for s in enrolled]
+ )
+ days_missed = [(~present[i]).sum() for i in range(n)]
+
+ always = np.ones((n_sims, n_days), dtype=bool)
+
+ # SD of a fully-attending class: the draw's natural spread.
+ full = np.ones((n, n_days), dtype=bool)
+ sd_full = float(np.std(
+ _simulate_counts(full, always, calls_per_day,
+ course.weight_factor, n_sims, rng)
+ ))
+
+ # Benchmark: focal always present, everyone else at real attendance.
+ probe = present.copy()
+ probe[0, :] = True
+ e_full_probe = float(np.mean(
+ _simulate_counts(probe, always, calls_per_day,
+ course.weight_factor, n_sims, rng)
+ ))
+
+ # Foregone curve: expected shortfall when the focal student misses
+ # k random days, averaged over placements and draws.
+ max_k = max(8, max(days_missed, default=0))
+ max_k = min(max_k, n_days)
+ curve = [0.0]
+ for k in range(1, max_k + 1):
+ focal_present = np.ones((n_sims, n_days), dtype=bool)
+ for row in range(n_sims):
+ focal_present[row, rng.choice(n_days, size=k, replace=False)] = False
+ counts = _simulate_counts(probe, focal_present, calls_per_day,
+ course.weight_factor, n_sims, rng)
+ curve.append(round(max(0.0, e_full_probe - float(np.mean(counts))), 4))
+
+ severity = (100.0 - course.passing_points) / sd_full
+ allowance_q = course.allowance_sd_units * sd_full
+
+ students = []
+ for i, s in enumerate(enrolled):
+ answered = inputs["points"].get(s.id, [])
+ q_pts = (sum(answered) / len(answered)) if answered else None
+ foregone = curve[min(days_missed[i], max_k)]
+ caught = len(
+ inputs["missing_days"].get(s.id, set())
+ - inputs["optout_days"].get(s.id, set())
+ )
+ if q_pts is None:
+ final = 0.0
+ else:
+ deduction = max(0.0, severity * (foregone - allowance_q))
+ final = max(
+ 0.0, q_pts - deduction - course.form_penalty_points * caught
+ )
+ students.append(
+ {
+ "student_id": s.id,
+ "name": s.name,
+ "asked": len(answered),
+ "days_missed": int(days_missed[i]),
+ "q_points": round(q_pts, 2) if q_pts is not None else None,
+ "foregone": round(foregone, 2),
+ "caught": caught,
+ "final_points": round(final, 2),
+ }
+ )
+ students.sort(key=lambda r: (r["days_missed"], -(r["q_points"] or -1)))
+
+ return {
+ "params": params,
+ "n_days": n_days,
+ "n_students": n,
+ "excluded_students": inputs["excluded_students"],
+ "excluded_calls": inputs["excluded_calls"],
+ "e_full": round(total_answered / n, 2),
+ "e_full_probe": round(e_full_probe, 2),
+ "sd_full": round(sd_full, 4),
+ "severity": round(severity, 4),
+ "allowance_q": round(allowance_q, 4),
+ "curve": curve,
+ "students": students,
+ }
+
+
+def run_and_store(db, course):
+ results = compute(db, course)
+ run = GradeRun(
+ course_id=course.id,
+ params=json.dumps(results.get("params", {})),
+ results=json.dumps(results),
+ )
+ db.add(run)
+ db.flush()
+ return run
diff --git a/coldcall_lti/instructor.py b/coldcall_lti/instructor.py
index ab30302..a89b5d5 100644
--- a/coldcall_lti/instructor.py
+++ b/coldcall_lti/instructor.py
@@ -1,6 +1,7 @@
import csv
import datetime
import io
+import json
from flask import (
Blueprint,
@@ -48,6 +49,15 @@ def _parse_date(value):
abort(400, "Bad date; expected YYYY-MM-DD.")
+def _parse_time(value):
+ if not value:
+ return None
+ try:
+ return datetime.time.fromisoformat(value)
+ except ValueError:
+ abort(400, "Bad time; expected HH:MM.")
+
+
def _get_course_call(db, call_id):
call = db.get(Call, call_id)
if call is None or call.course_id != current_course().id:
@@ -338,13 +348,16 @@ def export_optouts():
.where(OptOut.course_id == course.id)
.order_by(OptOut.date, Student.sortable_name)
).all()
+ # Withdrawn opt-outs are included: the export is the audit trail.
return _csv_response(
"optouts.csv",
- ["date", "name", "email", "canvas_user_id", "submitted_at"],
+ ["date", "name", "email", "canvas_user_id", "submitted_at",
+ "withdrawn_at"],
[
[
optout.date.isoformat(), student.name, student.email,
student.canvas_user_id, optout.created_at.isoformat(),
+ optout.withdrawn_at.isoformat() if optout.withdrawn_at else "",
]
for optout, student in rows
],
@@ -380,11 +393,16 @@ def schedule_generate():
if (end - start).days > 366:
abort(400, "Range longer than a year; check the dates.")
+ start_time = _parse_time(request.form.get("start_time"))
existing = {d.date for d in class_days(db, course.id)}
day = start
while day <= end:
if day.weekday() in chosen and day not in existing:
- db.add(ClassDay(course_id=course.id, date=day))
+ db.add(
+ ClassDay(
+ course_id=course.id, date=day, start_time=start_time
+ )
+ )
day += datetime.timedelta(days=1)
db.commit()
return redirect(url_for(".schedule"))
@@ -397,7 +415,13 @@ def schedule_add():
course = current_course()
day = _parse_date(request.form.get("date"))
if not any(d.date == day for d in class_days(db, course.id)):
- db.add(ClassDay(course_id=course.id, date=day))
+ db.add(
+ ClassDay(
+ course_id=course.id,
+ date=day,
+ start_time=_parse_time(request.form.get("start_time")),
+ )
+ )
db.commit()
return redirect(url_for(".schedule"))
@@ -506,5 +530,57 @@ def settings_save():
position=max((l.position for l in existing), default=-1) + 1,
)
)
+
+ # Grading parameters.
+ for field, low, high in [
+ ("allowance_sd_units", 0, 10),
+ ("passing_points", 0, 100),
+ ("form_penalty_points", 0, 100),
+ ]:
+ value = request.form.get(field, type=float)
+ if value is not None and low <= value <= high:
+ setattr(course, field, value)
+ n_sims = request.form.get("n_sims", type=int)
+ if n_sims and 100 <= n_sims <= 100000:
+ course.n_sims = n_sims
+ seed = request.form.get("sim_seed", type=int)
+ if seed is not None:
+ course.sim_seed = seed
+
+ # Display scale.
+ scale_type = request.form.get("scale_type")
+ if scale_type in ("uw4", "table", "none"):
+ course.scale_type = scale_type
+ scale_rows = request.form.get("scale_rows")
+ if scale_rows is not None:
+ rows = []
+ for line in scale_rows.splitlines():
+ parts = line.strip().split(None, 1)
+ if len(parts) == 2:
+ try:
+ rows.append([float(parts[0]), parts[1].strip()])
+ except ValueError:
+ pass
+ course.scale_config = (
+ json.dumps(sorted(rows, key=lambda r: -r[0])) if rows else None
+ )
+
+ course.publish_grade_reports = bool(
+ request.form.get("publish_grade_reports")
+ )
+ course.ags_enabled = bool(request.form.get("ags_enabled"))
+ db.commit()
+ return redirect(url_for(".settings"))
+
+
+@bp.post("/settings/import-scale")
+@require_instructor
+def settings_import_scale():
+ db = get_db()
+ course = current_course()
+ if not course.canvas_grading_scheme:
+ abort(400, "Canvas has not provided a grading scheme for this course.")
+ course.scale_config = course.canvas_grading_scheme
+ course.scale_type = "table"
db.commit()
return redirect(url_for(".settings"))
diff --git a/coldcall_lti/launch.py b/coldcall_lti/launch.py
index c7666da..eb4d126 100644
--- a/coldcall_lti/launch.py
+++ b/coldcall_lti/launch.py
@@ -26,6 +26,28 @@ def clean_custom_value(value):
return value
+def parse_grading_scheme(value):
+ """Canvas's $com.instructure.Course.gradingScheme expansion: a JSON
+ array of {name, value} grade levels. Normalized to our threshold
+ rows [[min_points, label], ...], highest first."""
+ import json
+
+ value = clean_custom_value(value)
+ if not value:
+ return None
+ try:
+ if isinstance(value, str):
+ value = json.loads(value)
+ rows = [
+ [float(level["value"]), str(level["name"])] for level in value
+ ]
+ except (ValueError, TypeError, KeyError):
+ return None
+ if not rows:
+ return None
+ return json.dumps(sorted(rows, key=lambda r: -r[0]))
+
+
def parse_custom_date(value):
"""Canvas date expansions arrive as ISO-ish timestamps; keep the
date part and tolerate absence or junk."""
@@ -54,6 +76,7 @@ class LaunchInfo:
resource_link_id: str | None
course_start: datetime.date | None
course_end: datetime.date | None
+ grading_scheme: str | None
is_instructor: bool
@@ -76,6 +99,7 @@ def extract_launch_info(launch_data):
resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"),
course_start=parse_custom_date(custom.get("course_start")),
course_end=parse_custom_date(custom.get("course_end")),
+ grading_scheme=parse_grading_scheme(custom.get("grading_scheme")),
is_instructor=roles_include_instructor(launch_data.get(CLAIM_ROLES, [])),
)
@@ -98,6 +122,8 @@ def upsert_launch(db, info):
course.start_date = info.course_start
if info.course_end:
course.end_date = info.course_end
+ if info.grading_scheme:
+ course.canvas_grading_scheme = info.grading_scheme
student = db.execute(
select(Student).where(Student.canvas_user_id == info.user_id)
diff --git a/coldcall_lti/lti.py b/coldcall_lti/lti.py
index cf6a063..d673dda 100644
--- a/coldcall_lti/lti.py
+++ b/coldcall_lti/lti.py
@@ -1,3 +1,5 @@
+import json
+
from flask import Blueprint, abort, current_app, jsonify, redirect, session, url_for
from pylti1p3.contrib.flask import (
FlaskCookieService,
@@ -18,6 +20,28 @@ from .roster import sync_roster
bp = Blueprint("lti", __name__, url_prefix="/lti")
NRPS_CLAIM = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice"
+AGS_CLAIM = "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"
+
+
+def ags_service_for_course(course):
+ """An AssignmentsGradesService usable outside a launch, or None if
+ the course hasn't recorded AGS details."""
+ from pylti1p3.assignments_grades import AssignmentsGradesService
+
+ if not (course.ags_lineitems_url and course.lti_issuer):
+ return None
+ registration = get_tool_conf().find_registration_by_params(
+ course.lti_issuer, client_id=course.lti_client_id
+ )
+ if registration is None:
+ return None
+ return AssignmentsGradesService(
+ ServiceConnector(registration),
+ {
+ "lineitems": course.ags_lineitems_url,
+ "scope": json.loads(course.ags_scopes or "[]"),
+ },
+ )
def nrps_members_for_course(course):
@@ -81,6 +105,10 @@ def launch():
nrps_data = launch_data.get(NRPS_CLAIM, {})
if nrps_data.get("context_memberships_url"):
course.nrps_url = nrps_data["context_memberships_url"]
+ ags_data = launch_data.get(AGS_CLAIM, {})
+ if ags_data.get("lineitems"):
+ course.ags_lineitems_url = ags_data["lineitems"]
+ course.ags_scopes = json.dumps(ags_data.get("scope", []))
# Instructor launches refresh the roster, so adds and drops are
# picked up before every class without a separate sync step.
diff --git a/coldcall_lti/models.py b/coldcall_lti/models.py
index 8c69ed3..4f0ab35 100644
--- a/coldcall_lti/models.py
+++ b/coldcall_lti/models.py
@@ -8,6 +8,7 @@ from sqlalchemy import (
ForeignKey,
String,
Text,
+ Time,
UniqueConstraint,
func,
select,
@@ -74,6 +75,33 @@ class Course(Base):
)
show_assessments: Mapped[bool] = mapped_column(Boolean, default=True)
+ # Grading parameters (participation_grades.R defaults, expressed in
+ # points out of 100; 65.15 pts and 4.55 pts are the UW-4.0 values
+ # 1.7 and 0.3 under the linear mapping).
+ allowance_sd_units: Mapped[float] = mapped_column(Float, default=0.25)
+ passing_points: Mapped[float] = mapped_column(Float, default=65.15)
+ form_penalty_points: Mapped[float] = mapped_column(Float, default=4.55)
+ n_sims: Mapped[int] = mapped_column(default=4000)
+ sim_seed: Mapped[int] = mapped_column(default=20260606)
+
+ # Display scale for grades: "uw4" (linear UW 4.0 map), "table"
+ # (threshold rows in scale_config JSON: [[min_points, label], ...]),
+ # or "none" (points only).
+ scale_type: Mapped[str] = mapped_column(String(16), default="uw4")
+ scale_config: Mapped[str | None] = mapped_column(Text)
+ # The course's grading scheme as Canvas reports it at launch
+ # (JSON [[min_points, label], ...]); import copies it into
+ # scale_config.
+ canvas_grading_scheme: Mapped[str | None] = mapped_column(Text)
+ publish_grade_reports: Mapped[bool] = mapped_column(Boolean, default=False)
+
+ # Gradebook passback: off by default; the push pages only exist
+ # when enabled. Endpoint details are captured at launch regardless,
+ # so enabling later needs no re-launch.
+ ags_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
+ ags_lineitems_url: Mapped[str | None] = mapped_column(String(1024))
+ ags_scopes: Mapped[str | None] = mapped_column(Text)
+
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow
)
@@ -166,6 +194,9 @@ class ClassDay(Base):
id: Mapped[int] = mapped_column(primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
date: Mapped[datetime.date] = mapped_column(Date)
+ # When class begins; bounds opt-out withdrawal. Optional: with no
+ # time recorded, same-day withdrawal is simply not allowed.
+ start_time: Mapped[datetime.time | None] = mapped_column(Time)
class OptOut(Base):
@@ -179,6 +210,10 @@ class OptOut(Base):
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow
)
+ # Withdrawals are recorded, never erased: an opt-out with
+ # withdrawn_at set no longer counts anywhere, but the audit trail
+ # of the change survives.
+ withdrawn_at: Mapped[datetime.datetime | None] = mapped_column(DateTime)
class Call(Base):
@@ -205,6 +240,30 @@ class Call(Base):
return self.level.label if self.level else None
+class GradeRun(Base):
+ """One computed grading result: the parameter snapshot and the full
+ per-student output, stored as JSON. Grade pages always show a run,
+ never a live computation, so what you reviewed is what you push."""
+
+ __tablename__ = "grade_runs"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
+ created_at: Mapped[datetime.datetime] = mapped_column(
+ DateTime, default=utcnow
+ )
+ params: Mapped[str] = mapped_column(Text)
+ results: Mapped[str] = mapped_column(Text)
+
+
+def latest_grade_run(session, course_id):
+ return session.execute(
+ select(GradeRun)
+ .where(GradeRun.course_id == course_id)
+ .order_by(GradeRun.created_at.desc(), GradeRun.id.desc())
+ ).scalars().first()
+
+
def answered_call_counts(session, course_id):
"""Number of answered calls per student, the input to selection
weighting. Skipped, pending, and missing calls do not count, matching
@@ -242,10 +301,31 @@ def is_class_day(session, course_id, on_date):
)
+def class_has_begun(session, course_id, day, now=None):
+ """Whether the class on `day` has started, for bounding opt-out
+ withdrawal. Without a recorded start time, the whole class day
+ counts as begun (the conservative reading)."""
+ now = now or datetime.datetime.now()
+ if day < now.date():
+ return True
+ if day > now.date():
+ return False
+ row = session.execute(
+ select(ClassDay).where(
+ ClassDay.course_id == course_id, ClassDay.date == day
+ )
+ ).scalar_one_or_none()
+ if row is None or row.start_time is None:
+ return True
+ return now.time() >= row.start_time
+
+
def students_present(session, course_id, on_date):
"""Active student-role enrollees minus those opted out for the date."""
opted_out = select(OptOut.student_id).where(
- OptOut.course_id == course_id, OptOut.date == on_date
+ OptOut.course_id == course_id,
+ OptOut.date == on_date,
+ OptOut.withdrawn_at.is_(None),
)
rows = session.execute(
select(Student)
diff --git a/coldcall_lti/reports.py b/coldcall_lti/reports.py
index 9f9979e..e421c8a 100644
--- a/coldcall_lti/reports.py
+++ b/coldcall_lti/reports.py
@@ -66,7 +66,9 @@ def student_stats(db, course):
optout_dates = defaultdict(set)
for optout in db.execute(
- select(OptOut).where(OptOut.course_id == course.id)
+ select(OptOut).where(
+ OptOut.course_id == course.id, OptOut.withdrawn_at.is_(None)
+ )
).scalars():
optout_dates[optout.student_id].add(optout.date)
diff --git a/coldcall_lti/scales.py b/coldcall_lti/scales.py
new file mode 100644
index 0000000..f9e6049
--- /dev/null
+++ b/coldcall_lti/scales.py
@@ -0,0 +1,42 @@
+"""Display scales: grades are computed in points out of 100; a
+per-course scale maps points to what humans read. "uw4" is the linear
+UW map from the old R scripts (100 -> 4.0, one grade step per
+15.15 points, clamped to [0, 4]); "table" maps through threshold rows
+[[min_points, label], ...] (letter grades); "none" shows points."""
+
+import json
+
+GPA_POINT_VALUE = 50 / (4 - 0.7)
+
+LETTER_TABLE = [
+ [93, "A"], [90, "A-"], [87, "B+"], [83, "B"], [80, "B-"],
+ [77, "C+"], [73, "C"], [70, "C-"], [67, "D+"], [63, "D"],
+ [60, "D-"], [0, "F"],
+]
+
+
+def to_uw4(points):
+ value = points / GPA_POINT_VALUE - (100 / GPA_POINT_VALUE - 4)
+ return round(min(4.0, max(0.0, value)), 2)
+
+
+def display(course, points):
+ if points is None:
+ return ""
+ if course.scale_type == "uw4":
+ return f"{to_uw4(points):.2f}"
+ if course.scale_type == "table":
+ rows = json.loads(course.scale_config or "[]")
+ for min_points, label in sorted(rows, key=lambda r: -r[0]):
+ if points >= min_points:
+ return str(label)
+ return str(rows[-1][1]) if rows else f"{points:.1f}"
+ return f"{points:.1f}"
+
+
+def ags_score(course, points):
+ """What to push to the gradebook: the numeric display value when
+ the scale is numeric, raw points otherwise."""
+ if course.scale_type == "uw4":
+ return to_uw4(points), 4.0
+ return round(points, 2), 100.0
diff --git a/coldcall_lti/templates/grade_report.html b/coldcall_lti/templates/grade_report.html
new file mode 100644
index 0000000..b14d97f
--- /dev/null
+++ b/coldcall_lti/templates/grade_report.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+{% block title %}Participation report — {{ student.name }}{% endblock %}
+{% block body %}
+{% if is_instructor_view %}
+← back to grades
+{% endif %}
+Case discussion participation report
+{{ student.name }}
+Participation grade: {{ final_display }}
+
+When you were called, and how it was assessed
+
+ Date Answered Assessment
+ {% for c in calls %}
+
+ {{ c.session_date.isoformat() }}
+ {{ "yes" if c.status == "answered" else "no (not present)" }}
+ {{ c.assessment or "—" }}
+
+ {% endfor %}
+
+You answered {{ row.asked }} of the
+ {{ calls | length }} times your name was drawn.
+
+How your grade was computed
+
+ Quality of your answers: {{ q_display }}
+ ({{ row.q_points }} points), the average of the assessments above.
+ Availability: you were available for
+ {{ results.n_days - row.days_missed }} of {{ results.n_days }}
+ class sessions (missed {{ row.days_missed }}).
+ Participation missed: about
+ {{ row.foregone }} questions' worth, after
+ accounting for the cold-call program calling on you more when you
+ return from an absence. A generous allowance (about
+ {{ "%.2f" | format(results.allowance_q) }} questions) is applied
+ before any shortfall affects your grade.
+ {% if row.caught > 0 %}
+ Opt-out form: {{ row.caught }} time(s) your name
+ was drawn while you were away without an opt-out on file
+ (−{{ results.params.form_penalty_points }} points each).
+ {% else %}
+ Opt-out form: no deductions.
+ {% endif %}
+ Final participation grade:
+ {{ final_display }} .
+
+
+Your grade depends only on the quality of your answers and on
+participation you missed by being unavailable. You are never penalized
+for how often the program happened to call on you, and the reason for
+any absence never enters the calculation.
+{% endblock %}
diff --git a/coldcall_lti/templates/grades.html b/coldcall_lti/templates/grades.html
new file mode 100644
index 0000000..89a55dc
--- /dev/null
+++ b/coldcall_lti/templates/grades.html
@@ -0,0 +1,72 @@
+{% extends "base.html" %}
+{% block title %}Grades — {{ course.title or "Cold Call" }}{% endblock %}
+{% block body %}
+← back ·
+ grading settings
+Participation grades
+
+
+
+{% if not results %}
+No grades computed yet. Computing runs the simulation
+ with the current settings; nothing is shown to students or sent
+ anywhere until you choose to.
+{% elif results.error %}
+{{ results.error }}
+{% else %}
+Parameters
+
+ Class days {{ results.n_days }}
+ Enrolled students {{ results.n_students }}
+ Equal share of questions {{ results.e_full }}
+ SD of full-attendance counts {{ "%.2f" | format(results.sd_full) }}
+ Severity (points per question) {{ "%.2f" | format(results.severity) }}
+ Allowance (questions) {{ "%.2f" | format(results.allowance_q) }}
+ = {{ results.params.allowance_sd_units }} SD
+
+{% if results.excluded_students %}
+{{ results.excluded_calls }} call(s) involving
+ {{ results.excluded_students }} dropped (inactive) student(s) are
+ excluded from all of the above.
+{% endif %}
+Foregone-participation curve by days missed:
+ {% for v in results.curve %}{{ loop.index0 }}d: {{ "%.2f" | format(v) }}{{ " · " if not loop.last }}{% endfor %}
+
+
+Grades
+
+
+ Student Asked Days missed Quality
+ Foregone Caught Final (pts) Grade
+
+ {% for r in results.students %}
+
+ {{ r.name }}
+ {{ r.asked }}
+ {{ r.days_missed or "" }}
+ {{ r.q_points if r.q_points is not none else "—" }}
+ {{ r.foregone or "" }}
+ {{ r.caught or "" }}
+ {{ r.final_points }}
+ {{ display(r.final_points) }}
+
+ report
+ {% if r.q_points is none %}never answered {% endif %}
+
+
+ {% endfor %}
+
+
+
+ Export grades.csv
+ {% if course.ags_enabled %}
+ · Review & push to gradebook
+ {% endif %}
+
+{% endif %}
+{% endblock %}
diff --git a/coldcall_lti/templates/instructor_home.html b/coldcall_lti/templates/instructor_home.html
index a6aa918..40eb520 100644
--- a/coldcall_lti/templates/instructor_home.html
+++ b/coldcall_lti/templates/instructor_home.html
@@ -11,6 +11,7 @@
Live cold call ·
Today's calls ·
Report ·
+ Grades ·
Schedule ·
Settings
diff --git a/coldcall_lti/templates/push_preview.html b/coldcall_lti/templates/push_preview.html
new file mode 100644
index 0000000..43e1a6c
--- /dev/null
+++ b/coldcall_lti/templates/push_preview.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+{% block title %}Push to gradebook — {{ course.title or "Cold Call" }}{% endblock %}
+{% block body %}
+← back to grades
+Review before pushing to the gradebook
+This will write the scores below to a "Case discussion
+ participation" column in the Canvas gradebook (computed
+ {{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC). Nothing is
+ sent until you confirm at the bottom.
+
+ Student Score to send Out of
+ {% for r in rows %}
+
+ {{ r.row.name }}
+ {{ r.score }}
+ {{ r.maximum }}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/coldcall_lti/templates/push_result.html b/coldcall_lti/templates/push_result.html
new file mode 100644
index 0000000..3d8e8bf
--- /dev/null
+++ b/coldcall_lti/templates/push_result.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Push results — {{ course.title or "Cold Call" }}{% endblock %}
+{% block body %}
+← back to grades
+Gradebook push results
+
+ Student Result
+ {% for o in outcomes %}
+
+ {{ o.name }}
+ {{ "✓" if o.ok else "FAILED:" }} {{ o.detail }}
+
+ {% endfor %}
+
+{{ outcomes | selectattr("ok") | list | length }} of
+ {{ outcomes | length }} pushed successfully.
+{% endblock %}
diff --git a/coldcall_lti/templates/schedule.html b/coldcall_lti/templates/schedule.html
index 47ca44a..66fde95 100644
--- a/coldcall_lti/templates/schedule.html
+++ b/coldcall_lti/templates/schedule.html
@@ -5,7 +5,9 @@
Class schedule
When class days are listed here, the student opt-out
form only offers these dates. With no days listed, students can pick
- any date.
+ any date. Start times bound opt-out withdrawal: students can withdraw
+ an opt-out until class begins; with no time recorded, withdrawal
+ closes at the start of the class day.
{% set dmin = course.start_date.isoformat() if course.start_date else "" %}
{% set dmax = course.end_date.isoformat() if course.end_date else "" %}
@@ -15,6 +17,7 @@
value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}">
to
+ starting at
{% for i, name in weekdays %}
{{ name }}
{% endfor %}
@@ -25,6 +28,7 @@
@@ -34,7 +38,9 @@
{% for d in days %}
- {{ d.date.strftime("%a %b %-d, %Y") }}
+ {{ d.date.strftime("%a %b %-d, %Y") }}
+ {% if d.start_time %}{{ d.start_time.strftime("%H:%M") }} {% endif %}
+
+ Grading
+ Grades use the timing-neutral foregone-participation
+ scheme: answer quality minus a deduction for participation missed
+ through unavailability, estimated by simulating the cold-call draw.
+ All values are in points out of 100.
+
+ Allowance (SD units):
+
+ Foregone participation forgiven before any
+ deduction, in units of the draw's natural spread.
+
+
+ Passing line (points):
+
+ Forgoing one SD of participation lands a
+ perfect-quality student here (65.15 = UW 1.7).
+
+
+ Form-filing penalty (points):
+
+ Per day called while absent with no opt-out
+ filed (4.55 = UW 0.3).
+
+
+ Simulation iterations:
+
+ Seed:
+
+
+
+ Grade display scale
+
+ Scale:
+
+ UW 4.0 (linear)
+ threshold table
+ points only
+
+
+
+
+ Threshold rows (one per line: minimum points, then label):
+
+
+
+
+
+ Publish grade reports to students (adds a report to each
+ student's page once grades are computed)
+
+
+
+ Enable gradebook passback (adds a review-then-push page; nothing
+ is ever sent without explicit confirmation)
+
+
Save
+
+{% if course.canvas_grading_scheme %}
+
+{% endif %}
{% endblock %}
diff --git a/coldcall_lti/templates/student_home.html b/coldcall_lti/templates/student_home.html
index 23e54ee..0ea3dbd 100644
--- a/coldcall_lti/templates/student_home.html
+++ b/coldcall_lti/templates/student_home.html
@@ -7,6 +7,11 @@
they are not right, update them in your Canvas account settings and
they will be picked up here automatically.
+{% if report_available %}
+Your
+ participation grade report
+{% endif %}
+
Your standing
You have answered {{ answered }}
@@ -47,22 +52,23 @@
{% if optouts %}
Date
- {% for o in optouts %}
+ {% for o, withdrawable in optouts %}
{{ o.date.isoformat() }}
- {% if o.date >= today %}
+ {% if withdrawable %}
{% else %}
- past
+ locked
{% endif %}
{% endfor %}
+Opt-outs can be withdrawn until the class begins.
{% else %}
You have not opted out of any classes.
{% endif %}
diff --git a/coldcall_lti/views.py b/coldcall_lti/views.py
index 78bc4e4..c70deb0 100644
--- a/coldcall_lti/views.py
+++ b/coldcall_lti/views.py
@@ -16,7 +16,9 @@ from .models import (
Student,
answered_call_counts,
class_days,
+ class_has_begun,
is_class_day,
+ latest_grade_run,
)
bp = Blueprint("views", __name__)
@@ -119,17 +121,22 @@ def student_home():
db.execute(
select(OptOut)
.where(
- OptOut.course_id == course.id, OptOut.student_id == user.id
+ OptOut.course_id == course.id,
+ OptOut.student_id == user.id,
+ OptOut.withdrawn_at.is_(None),
)
.order_by(OptOut.date)
)
.scalars()
.all()
)
+ optouts = [
+ (o, not class_has_begun(db, course.id, o.date)) for o in optouts
+ ]
upcoming_days = [
d.date
for d in class_days(db, course.id, start=today)
- if d.date not in {o.date for o in optouts}
+ if d.date not in {o.date for o, _ in optouts}
]
return render_template(
@@ -143,6 +150,34 @@ def student_home():
optouts=optouts,
upcoming_days=upcoming_days,
distribution=_class_call_distribution(db, course, user.id),
+ report_available=(
+ course.publish_grade_reports
+ and latest_grade_run(db, course.id) is not None
+ ),
+ )
+
+
+@bp.get("/me/report")
+@require_launch
+def my_grade_report():
+ import json
+
+ from .grades import student_report_context
+
+ db = get_db()
+ course = current_course()
+ user = current_user()
+ if not course.publish_grade_reports:
+ abort(404)
+ run = latest_grade_run(db, course.id)
+ if run is None:
+ abort(404)
+ results = json.loads(run.results)
+ context = student_report_context(db, course, results, user.id)
+ if context is None:
+ abort(404)
+ return render_template(
+ "grade_report.html", is_instructor_view=False, **context
)
@@ -172,6 +207,11 @@ def optout_create():
if exists is None:
db.add(OptOut(course_id=course.id, student_id=user.id, date=day))
db.commit()
+ elif exists.withdrawn_at is not None:
+ # Re-opting-out after a withdrawal reuses the row; the
+ # withdrawal timestamp is cleared but creation time remains.
+ exists.withdrawn_at = None
+ db.commit()
return redirect(url_for(".student_home"))
@@ -184,10 +224,11 @@ def optout_delete(optout_id):
optout is None
or optout.course_id != current_course().id
or optout.student_id != current_user().id
+ or optout.withdrawn_at is not None
):
abort(404)
- if optout.date < datetime.date.today():
- abort(400, "Past opt-outs cannot be withdrawn.")
- db.delete(optout)
+ if class_has_begun(db, optout.course_id, optout.date):
+ abort(400, "Opt-outs cannot be withdrawn once class has begun.")
+ optout.withdrawn_at = datetime.datetime.now(datetime.UTC)
db.commit()
return redirect(url_for(".student_home"))
diff --git a/migrations/versions/3b6f28fbdc4b_class_start_times_and_optout_withdrawal_.py b/migrations/versions/3b6f28fbdc4b_class_start_times_and_optout_withdrawal_.py
new file mode 100644
index 0000000..5818e56
--- /dev/null
+++ b/migrations/versions/3b6f28fbdc4b_class_start_times_and_optout_withdrawal_.py
@@ -0,0 +1,40 @@
+"""class start times and optout withdrawal audit
+
+Revision ID: 3b6f28fbdc4b
+Revises: 826e2835e289
+Create Date: 2026-07-31 18:35:27.102919
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '3b6f28fbdc4b'
+down_revision: Union[str, None] = '826e2835e289'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('class_days', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('start_time', sa.Time(), nullable=True))
+
+ with op.batch_alter_table('optouts', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('withdrawn_at', sa.DateTime(), nullable=True))
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('optouts', schema=None) as batch_op:
+ batch_op.drop_column('withdrawn_at')
+
+ with op.batch_alter_table('class_days', schema=None) as batch_op:
+ batch_op.drop_column('start_time')
+
+ # ### end Alembic commands ###
diff --git a/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py b/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py
deleted file mode 100644
index 1c78e4f..0000000
--- a/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""assessment levels and nrps details
-
-Revision ID: 826e2835e289
-Revises: 487d155678ea
-Create Date: 2026-07-31 16:56:00.716357
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = '826e2835e289'
-down_revision: Union[str, None] = '487d155678ea'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- # ### commands auto generated by Alembic - please adjust! ###
- with op.batch_alter_table('assessment_levels', schema=None) as batch_op:
- batch_op.create_unique_constraint(batch_op.f('uq_assessment_levels_course_id'), ['course_id', 'label'])
-
- with op.batch_alter_table('calls', schema=None) as batch_op:
- batch_op.create_foreign_key(batch_op.f('fk_calls_assessment_id_assessment_levels'), 'assessment_levels', ['assessment_id'], ['id'])
- batch_op.drop_column('assessment')
-
- with op.batch_alter_table('class_days', schema=None) as batch_op:
- batch_op.create_unique_constraint(batch_op.f('uq_class_days_course_id'), ['course_id', 'date'])
-
- with op.batch_alter_table('courses', schema=None) as batch_op:
- batch_op.add_column(sa.Column('lti_issuer', sa.String(length=255), nullable=True))
- batch_op.add_column(sa.Column('lti_client_id', sa.String(length=255), nullable=True))
- batch_op.add_column(sa.Column('nrps_url', sa.String(length=1024), nullable=True))
- batch_op.create_unique_constraint(batch_op.f('uq_courses_lti_context_id'), ['lti_context_id'])
-
- with op.batch_alter_table('enrollments', schema=None) as batch_op:
- batch_op.create_unique_constraint(batch_op.f('uq_enrollments_course_id'), ['course_id', 'student_id'])
-
- with op.batch_alter_table('optouts', schema=None) as batch_op:
- batch_op.create_unique_constraint(batch_op.f('uq_optouts_course_id'), ['course_id', 'student_id', 'date'])
-
- with op.batch_alter_table('students', schema=None) as batch_op:
- batch_op.create_unique_constraint(batch_op.f('uq_students_canvas_user_id'), ['canvas_user_id'])
-
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- # ### commands auto generated by Alembic - please adjust! ###
- with op.batch_alter_table('students', schema=None) as batch_op:
- batch_op.drop_constraint(batch_op.f('uq_students_canvas_user_id'), type_='unique')
-
- with op.batch_alter_table('optouts', schema=None) as batch_op:
- batch_op.drop_constraint(batch_op.f('uq_optouts_course_id'), type_='unique')
-
- with op.batch_alter_table('enrollments', schema=None) as batch_op:
- batch_op.drop_constraint(batch_op.f('uq_enrollments_course_id'), type_='unique')
-
- with op.batch_alter_table('courses', schema=None) as batch_op:
- batch_op.drop_constraint(batch_op.f('uq_courses_lti_context_id'), type_='unique')
- batch_op.drop_column('nrps_url')
- batch_op.drop_column('lti_client_id')
- batch_op.drop_column('lti_issuer')
-
- with op.batch_alter_table('class_days', schema=None) as batch_op:
- batch_op.drop_constraint(batch_op.f('uq_class_days_course_id'), type_='unique')
-
- with op.batch_alter_table('calls', schema=None) as batch_op:
- batch_op.add_column(sa.Column('assessment', sa.VARCHAR(length=32), nullable=True))
- batch_op.drop_constraint(batch_op.f('fk_calls_assessment_id_assessment_levels'), type_='foreignkey')
-
- with op.batch_alter_table('assessment_levels', schema=None) as batch_op:
- batch_op.drop_constraint(batch_op.f('uq_assessment_levels_course_id'), type_='unique')
-
- # ### end Alembic commands ###
diff --git a/migrations/versions/826e2835e289_assessment_levels_grading_services.py b/migrations/versions/826e2835e289_assessment_levels_grading_services.py
new file mode 100644
index 0000000..dfa7135
--- /dev/null
+++ b/migrations/versions/826e2835e289_assessment_levels_grading_services.py
@@ -0,0 +1,93 @@
+"""assessment levels, grading, and platform service details
+
+Revision ID: 826e2835e289
+Revises: 487d155678ea
+Create Date: 2026-07-31 18:21:06.340614
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '826e2835e289'
+down_revision: Union[str, None] = '487d155678ea'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('assessment_levels',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('course_id', sa.Integer(), nullable=False),
+ sa.Column('label', sa.String(length=64), nullable=False),
+ sa.Column('points', sa.Float(), nullable=False),
+ sa.Column('position', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['course_id'], ['courses.id'], name=op.f('fk_assessment_levels_course_id_courses')),
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_assessment_levels')),
+ sa.UniqueConstraint('course_id', 'label', name=op.f('uq_assessment_levels_course_id'))
+ )
+ op.create_table('grade_runs',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('course_id', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('params', sa.Text(), nullable=False),
+ sa.Column('results', sa.Text(), nullable=False),
+ sa.ForeignKeyConstraint(['course_id'], ['courses.id'], name=op.f('fk_grade_runs_course_id_courses')),
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_grade_runs'))
+ )
+ with op.batch_alter_table('calls', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('assessment_id', sa.Integer(), nullable=True))
+ batch_op.create_foreign_key(batch_op.f('fk_calls_assessment_id_assessment_levels'), 'assessment_levels', ['assessment_id'], ['id'])
+ batch_op.drop_column('assessment')
+
+ with op.batch_alter_table('courses', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('lti_issuer', sa.String(length=255), nullable=True))
+ batch_op.add_column(sa.Column('lti_client_id', sa.String(length=255), nullable=True))
+ batch_op.add_column(sa.Column('nrps_url', sa.String(length=1024), nullable=True))
+ batch_op.add_column(sa.Column('allowance_sd_units', sa.Float(), nullable=False))
+ batch_op.add_column(sa.Column('passing_points', sa.Float(), nullable=False))
+ batch_op.add_column(sa.Column('form_penalty_points', sa.Float(), nullable=False))
+ batch_op.add_column(sa.Column('n_sims', sa.Integer(), nullable=False))
+ batch_op.add_column(sa.Column('sim_seed', sa.Integer(), nullable=False))
+ batch_op.add_column(sa.Column('scale_type', sa.String(length=16), nullable=False))
+ batch_op.add_column(sa.Column('scale_config', sa.Text(), nullable=True))
+ batch_op.add_column(sa.Column('canvas_grading_scheme', sa.Text(), nullable=True))
+ batch_op.add_column(sa.Column('publish_grade_reports', sa.Boolean(), nullable=False))
+ batch_op.add_column(sa.Column('ags_enabled', sa.Boolean(), nullable=False))
+ batch_op.add_column(sa.Column('ags_lineitems_url', sa.String(length=1024), nullable=True))
+ batch_op.add_column(sa.Column('ags_scopes', sa.Text(), nullable=True))
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('courses', schema=None) as batch_op:
+ batch_op.drop_column('ags_scopes')
+ batch_op.drop_column('ags_lineitems_url')
+ batch_op.drop_column('ags_enabled')
+ batch_op.drop_column('publish_grade_reports')
+ batch_op.drop_column('canvas_grading_scheme')
+ batch_op.drop_column('scale_config')
+ batch_op.drop_column('scale_type')
+ batch_op.drop_column('sim_seed')
+ batch_op.drop_column('n_sims')
+ batch_op.drop_column('form_penalty_points')
+ batch_op.drop_column('passing_points')
+ batch_op.drop_column('allowance_sd_units')
+ batch_op.drop_column('nrps_url')
+ batch_op.drop_column('lti_client_id')
+ batch_op.drop_column('lti_issuer')
+
+ with op.batch_alter_table('calls', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('assessment', sa.VARCHAR(length=32), nullable=True))
+ batch_op.drop_constraint(batch_op.f('fk_calls_assessment_id_assessment_levels'), type_='foreignkey')
+ batch_op.drop_column('assessment_id')
+
+ op.drop_table('grade_runs')
+ op.drop_table('assessment_levels')
+ # ### end Alembic commands ###
diff --git a/tests/test_grades_ui.py b/tests/test_grades_ui.py
new file mode 100644
index 0000000..3d52548
--- /dev/null
+++ b/tests/test_grades_ui.py
@@ -0,0 +1,123 @@
+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")
+ # Speed: shrink the simulation for tests.
+ dev_client.post(
+ "/instructor/settings",
+ data={"selection_mode": "weighted", "weight_factor": "2",
+ "show_assessments": "on", "n_sims": "200", "sim_seed": "1"},
+ )
+ return dev_client
+
+
+def record_calls(client, n=6):
+ for _ in range(n):
+ 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)
+ client.post(
+ f"/instructor/call/{call_id}/outcome",
+ data={"action": outcome_actions(page)["GOOD"]},
+ )
+
+
+def test_grades_page_before_compute(instructor):
+ page = instructor.get("/instructor/grades/").get_data(as_text=True)
+ assert "No grades computed yet" in page
+
+
+def test_compute_and_view_grades(instructor):
+ record_calls(instructor)
+ resp = instructor.post(
+ "/instructor/grades/compute", follow_redirects=True
+ )
+ page = resp.get_data(as_text=True)
+ assert "SD of full-attendance counts" in page
+ assert "4.00" in page # someone answered GOOD -> full marks
+ assert "never answered" in page # not everyone got called
+
+ resp = instructor.get("/instructor/grades/export.csv")
+ assert resp.mimetype == "text/csv"
+ assert "final_points" in resp.get_data(as_text=True)
+
+
+def test_student_report_and_publishing(instructor):
+ record_calls(instructor)
+ instructor.post("/instructor/grades/compute")
+ page = instructor.get("/instructor/grades/").get_data(as_text=True)
+ student_id = re.search(r"/instructor/grades/student/(\d+)", page).group(1)
+ report = instructor.get(
+ f"/instructor/grades/student/{student_id}"
+ ).get_data(as_text=True)
+ assert "How your grade was computed" in report
+
+ # Unpublished: students see no report link and get 404.
+ instructor.post("/dev/launch/dev-student-1")
+ assert "participation grade report" not in instructor.get("/me").get_data(as_text=True)
+ assert instructor.get("/me/report").status_code == 404
+
+ # Publish, and the student can read their own report.
+ instructor.post("/dev/launch/dev-instructor")
+ instructor.post(
+ "/instructor/settings",
+ data={"selection_mode": "weighted", "weight_factor": "2",
+ "publish_grade_reports": "on"},
+ )
+ instructor.post("/dev/launch/dev-student-1")
+ page = instructor.get("/me").get_data(as_text=True)
+ assert "participation grade report" in page
+ report = instructor.get("/me/report").get_data(as_text=True)
+ assert "How your grade was computed" in report
+ assert "Ada Lovelace" in report
+
+
+def test_push_flow_dev_mode(instructor):
+ record_calls(instructor)
+ instructor.post("/instructor/grades/compute")
+
+ # Push pages don't exist until AGS is enabled.
+ assert instructor.get("/instructor/grades/push").status_code == 404
+
+ instructor.post(
+ "/instructor/settings",
+ data={"selection_mode": "weighted", "weight_factor": "2",
+ "ags_enabled": "on"},
+ )
+ page = instructor.get("/instructor/grades/push").get_data(as_text=True)
+ assert "Review before pushing" in page
+ assert "Push 8 grades to Canvas" in page
+
+ resp = instructor.post("/instructor/grades/push")
+ page = resp.get_data(as_text=True)
+ assert "simulated (dev mode)" in page
+ assert "8 of\n 8 pushed successfully" in page or "8 of" in page
+
+
+def test_grades_require_instructor(dev_client):
+ dev_client.post("/dev/launch/dev-instructor")
+ dev_client.post("/dev/launch/dev-student-1")
+ assert dev_client.get("/instructor/grades/").status_code == 403
+
+
+def test_scale_settings_and_canvas_import(instructor):
+ # Threshold rows via the settings form.
+ instructor.post(
+ "/instructor/settings",
+ data={"selection_mode": "weighted", "weight_factor": "2",
+ "scale_type": "table",
+ "scale_rows": "93 A\n90 A-\n0 F"},
+ )
+ record_calls(instructor, n=3)
+ instructor.post("/instructor/grades/compute")
+ page = instructor.get("/instructor/grades/").get_data(as_text=True)
+ assert "A " in page
diff --git a/tests/test_grading.py b/tests/test_grading.py
new file mode 100644
index 0000000..7e5f988
--- /dev/null
+++ b/tests/test_grading.py
@@ -0,0 +1,238 @@
+import datetime
+
+import pytest
+
+from coldcall_lti import grading, models, scales
+
+D0 = datetime.date(2026, 10, 1)
+
+
+def setup_course(db, n_students=6, n_days=10, calls_per_day=3,
+ n_sims=400, **course_kw):
+ course = models.Course(
+ lti_context_id="ctx-1", n_sims=n_sims, sim_seed=1, **course_kw
+ )
+ db.add(course)
+ db.flush()
+ models.ensure_default_levels(db, course)
+ levels = {
+ lvl.label: lvl for lvl in models.assessment_levels(db, course.id)
+ }
+ students = []
+ for i in range(n_students):
+ 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()
+
+ # Round-robin answered calls, all GOOD, over n_days class days.
+ days = [D0 + datetime.timedelta(days=i) for i in range(n_days)]
+ idx = 0
+ for day in days:
+ for _ in range(calls_per_day):
+ db.add(
+ models.Call(
+ course_id=course.id,
+ student_id=students[idx % n_students].id,
+ session_date=day,
+ status=models.STATUS_ANSWERED,
+ assessment_id=levels["GOOD"].id,
+ )
+ )
+ idx += 1
+ db.flush()
+ return course, students, days, levels
+
+
+def test_all_present_all_good_grades_full(db_session):
+ course, students, days, levels = setup_course(db_session)
+ results = grading.compute(db_session, course)
+ assert results["curve"][0] == 0.0
+ for row in results["students"]:
+ assert row["q_points"] == 100.0
+ assert row["days_missed"] == 0
+ assert row["final_points"] == 100.0
+
+
+def test_foregone_curve_is_nonnegative_and_grows(db_session):
+ course, *_ = setup_course(db_session)
+ results = grading.compute(db_session, course)
+ curve = results["curve"]
+ assert all(v >= 0 for v in curve)
+ # Missing most days must cost more than missing one.
+ assert curve[8] > curve[1]
+
+
+def test_zero_answer_student_floors_to_zero(db_session):
+ course, students, days, levels = setup_course(db_session)
+ extra = models.Student(canvas_user_id="u-silent", name="Silent Student")
+ db_session.add(extra)
+ db_session.flush()
+ db_session.add(
+ models.Enrollment(course_id=course.id, student_id=extra.id)
+ )
+ db_session.flush()
+ results = grading.compute(db_session, course)
+ row = next(
+ r for r in results["students"] if r["student_id"] == extra.id
+ )
+ assert row["q_points"] is None
+ assert row["final_points"] == 0.0
+
+
+def test_luck_protection_present_but_rarely_called(db_session):
+ """A student who is always available but was called only once is
+ not penalized: quality is their grade."""
+ course, students, days, levels = setup_course(db_session)
+ lucky = models.Student(canvas_user_id="u-lucky", name="Lucky Student")
+ db_session.add(lucky)
+ db_session.flush()
+ db_session.add(
+ models.Enrollment(course_id=course.id, student_id=lucky.id)
+ )
+ db_session.add(
+ models.Call(
+ course_id=course.id,
+ student_id=lucky.id,
+ session_date=days[0],
+ status=models.STATUS_ANSWERED,
+ assessment_id=levels["POOR"].id,
+ )
+ )
+ db_session.flush()
+ results = grading.compute(db_session, course)
+ row = next(
+ r for r in results["students"] if r["student_id"] == lucky.id
+ )
+ assert row["days_missed"] == 0
+ assert row["final_points"] == pytest.approx(69.7, abs=0.01)
+
+
+def test_absences_within_allowance_are_free(db_session):
+ course, students, days, levels = setup_course(
+ db_session, allowance_sd_units=5.0
+ )
+ # One student opts out of two days.
+ for day in days[:2]:
+ db_session.add(
+ models.OptOut(
+ course_id=course.id, student_id=students[0].id, date=day
+ )
+ )
+ db_session.flush()
+ results = grading.compute(db_session, course)
+ row = next(
+ r for r in results["students"]
+ if r["student_id"] == students[0].id
+ )
+ assert row["days_missed"] == 2
+ # Huge allowance: no deduction despite the absences.
+ assert row["final_points"] == row["q_points"]
+
+
+def test_heavy_absence_is_deducted(db_session):
+ course, students, days, levels = setup_course(
+ db_session, allowance_sd_units=0.25
+ )
+ for day in days[:8]:
+ db_session.add(
+ models.OptOut(
+ course_id=course.id, student_id=students[0].id, date=day
+ )
+ )
+ db_session.flush()
+ results = grading.compute(db_session, course)
+ row = next(
+ r for r in results["students"]
+ if r["student_id"] == students[0].id
+ )
+ assert row["final_points"] < row["q_points"]
+
+
+def test_form_penalty_only_for_uncovered_missing_days(db_session):
+ course, students, days, levels = setup_course(db_session)
+ caught_s, covered_s = students[0], students[1]
+ # Both drawn-but-missing on day 3; only covered_s filed the form.
+ for s in (caught_s, covered_s):
+ db_session.add(
+ models.Call(
+ course_id=course.id,
+ student_id=s.id,
+ session_date=days[3],
+ status=models.STATUS_MISSING,
+ )
+ )
+ db_session.add(
+ models.OptOut(
+ course_id=course.id, student_id=covered_s.id, date=days[3]
+ )
+ )
+ db_session.flush()
+ results = grading.compute(db_session, course)
+ rows = {r["student_id"]: r for r in results["students"]}
+ assert rows[caught_s.id]["caught"] == 1
+ assert rows[covered_s.id]["caught"] == 0
+ # Both have the same quality and same availability; the caught
+ # student ends exactly one form penalty lower.
+ diff = (rows[covered_s.id]["final_points"]
+ - rows[caught_s.id]["final_points"])
+ assert diff == pytest.approx(course.form_penalty_points, abs=0.01)
+
+
+def test_dropped_students_are_excluded_everywhere(db_session):
+ course, students, days, levels = setup_course(db_session)
+ baseline = grading.compute(db_session, course)
+
+ dropped = models.Student(canvas_user_id="u-dropped", name="Dropped")
+ db_session.add(dropped)
+ db_session.flush()
+ db_session.add(
+ models.Enrollment(
+ course_id=course.id, student_id=dropped.id, active=False
+ )
+ )
+ for day in days[:3]:
+ db_session.add(
+ models.Call(
+ course_id=course.id,
+ student_id=dropped.id,
+ session_date=day,
+ status=models.STATUS_ANSWERED,
+ assessment_id=levels["GOOD"].id,
+ )
+ )
+ db_session.flush()
+
+ results = grading.compute(db_session, course)
+ assert results["excluded_students"] == 1
+ assert results["excluded_calls"] == 3
+ assert results["n_students"] == baseline["n_students"]
+ assert dropped.id not in {r["student_id"] for r in results["students"]}
+ # Same seed, same effective inputs: identical simulation results.
+ assert results["sd_full"] == baseline["sd_full"]
+ assert results["curve"] == baseline["curve"]
+
+
+def test_scales():
+ course = models.Course(lti_context_id="x", scale_type="uw4")
+ assert scales.display(course, 100.0) == "4.00"
+ assert scales.display(course, 65.15) == "1.70"
+ assert scales.display(course, 84.85) == "3.00"
+ assert scales.display(course, 0.0) == "0.00"
+
+ course.scale_type = "table"
+ course.scale_config = '[[93, "A"], [90, "A-"], [0, "F"]]'
+ assert scales.display(course, 95) == "A"
+ assert scales.display(course, 91.2) == "A-"
+ assert scales.display(course, 10) == "F"
+
+ course.scale_type = "none"
+ assert scales.display(course, 87.5) == "87.5"
+
+ course.scale_type = "uw4"
+ assert scales.ags_score(course, 84.85) == (3.0, 4.0)
+ course.scale_type = "table"
+ assert scales.ags_score(course, 84.85) == (84.85, 100.0)
diff --git a/tests/test_student_ui.py b/tests/test_student_ui.py
index ab5d200..5b54b23 100644
--- a/tests/test_student_ui.py
+++ b/tests/test_student_ui.py
@@ -88,6 +88,73 @@ def test_students_cannot_touch_others_optouts(student):
assert student.post(f"/me/optout/{optout_id}/delete").status_code == 404
+def test_withdrawal_locks_when_class_begins(student):
+ # Dev class days start at 23:59, so today's class hasn't begun and
+ # withdrawal works (covered by test_optout_roundtrip). Move a class
+ # day's start time into the past and withdrawal must be refused.
+ from coldcall_lti.models import ClassDay
+
+ page = student.get("/me").get_data(as_text=True)
+ day = next_class_day_iso(page)
+ student.post("/me/optout", data={"date": day})
+ page = student.get("/me").get_data(as_text=True)
+ optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
+
+ # Reach into the DB to set that class day's start time to 00:00.
+ app = student.application
+ with app.app_context():
+ db = app.extensions["db_session_factory"]()
+ class_day = (
+ db.query(ClassDay)
+ .filter_by(date=datetime.date.fromisoformat(day))
+ .one()
+ )
+ began_today = class_day.date == datetime.date.today()
+ class_day.start_time = datetime.time(0, 0)
+ db.commit()
+ db.close()
+
+ resp = student.post(f"/me/optout/{optout_id}/delete")
+ if began_today:
+ assert resp.status_code == 400
+ page = student.get("/me").get_data(as_text=True)
+ assert "locked" in page
+ else:
+ # The day is still in the future; withdrawal stays open.
+ assert resp.status_code == 302
+
+
+def test_withdrawal_is_recorded_not_deleted(student):
+ page = student.get("/me").get_data(as_text=True)
+ day = next_class_day_iso(page)
+ student.post("/me/optout", data={"date": day})
+ page = student.get("/me").get_data(as_text=True)
+ optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
+ student.post(f"/me/optout/{optout_id}/delete")
+
+ from coldcall_lti.models import OptOut
+
+ app = student.application
+ with app.app_context():
+ db = app.extensions["db_session_factory"]()
+ optout = db.query(OptOut).one()
+ assert optout.withdrawn_at is not None
+ db.close()
+
+ # Re-opting out reinstates the same row.
+ student.post("/me/optout", data={"date": day})
+ with app.app_context():
+ db = app.extensions["db_session_factory"]()
+ optout = db.query(OptOut).one()
+ assert optout.withdrawn_at is None
+ db.close()
+
+ # The instructor's opt-out export keeps the audit column.
+ student.post("/dev/launch/dev-instructor")
+ csv_text = student.get("/instructor/export/optouts.csv").get_data(as_text=True)
+ assert "withdrawn_at" in csv_text.splitlines()[0]
+
+
def test_assessment_visibility_setting(student):
# Record an answered call for dev-student-1 as the instructor.
student.post("/dev/launch/dev-instructor")