1
0

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 18:38:22 -07:00
parent 7ec1be5dc6
commit 46f7f55633
27 changed files with 1869 additions and 98 deletions

View File

@@ -103,6 +103,7 @@ configuration: add a custom parameter
pronouns=$com.instructure.Person.pronouns pronouns=$com.instructure.Person.pronouns
course_start=$Canvas.course.startAt course_start=$Canvas.course.startAt
course_end=$Canvas.course.endAt course_end=$Canvas.course.endAt
grading_scheme=$com.instructure.Course.gradingScheme
``` ```
The course dates bound the schedule and date pickers; the tool works 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, and each student's own page. If the account has pronouns disabled,
everything simply shows without them. 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 <dir>` imports a manual-era class directory
for this kind of testing.
## Developing without Canvas ## Developing without Canvas
Because a Developer Key takes institutional approval to get, the app Because a Developer Key takes institutional approval to get, the app

View File

@@ -1,3 +1,4 @@
import json
import os import os
from flask import Flask from flask import Flask
@@ -23,11 +24,14 @@ def create_app(config=Config):
app.config["SESSION_COOKIE_SAMESITE"] = "None" app.config["SESSION_COOKIE_SAMESITE"] = "None"
app.config["SESSION_COOKIE_SECURE"] = True 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(lti.bp)
app.register_blueprint(views.bp) app.register_blueprint(views.bp)
app.register_blueprint(instructor.bp) app.register_blueprint(instructor.bp)
app.register_blueprint(grades.bp)
if app.config["DEV_MODE"]: if app.config["DEV_MODE"]:
from . import dev from . import dev

View File

@@ -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 17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
""" """
import csv
import datetime import datetime
import glob
import json
import os
import re
import click import click
from flask import current_app from flask import current_app
from sqlalchemy import select 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 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): 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") @app.cli.command("sync-rosters")
def sync_rosters(): def sync_rosters():
"""Refresh every syncable course roster from its LMS.""" """Refresh every syncable course roster from its LMS."""
@@ -48,3 +90,156 @@ def register_cli(app):
) )
finally: finally:
db.close() 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"
)

View File

@@ -76,14 +76,21 @@ def _ensure_dev_course(db):
ensure_default_levels(db, course) ensure_default_levels(db, course)
sync_roster(db, course, DEV_MEMBERS) sync_roster(db, course, DEV_MEMBERS)
# A Tue/Thu meeting pattern for the next few weeks, so the opt-out # A Tue/Thu 23:59 meeting pattern for the next few weeks, so the
# picker and schedule pages have something to show. # 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): if not class_days(db, course.id):
day = datetime.date.today() day = datetime.date.today()
for offset in range(28): for offset in range(28):
d = day + datetime.timedelta(days=offset) d = day + datetime.timedelta(days=offset)
if d.weekday() in (1, 3): 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() db.commit()
return course return course

217
coldcall_lti/grades.py Normal file
View File

@@ -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/<int:student_id>")
@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
)

270
coldcall_lti/grading.py Normal file
View File

@@ -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

View File

@@ -1,6 +1,7 @@
import csv import csv
import datetime import datetime
import io import io
import json
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -48,6 +49,15 @@ def _parse_date(value):
abort(400, "Bad date; expected YYYY-MM-DD.") 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): def _get_course_call(db, call_id):
call = db.get(Call, call_id) call = db.get(Call, call_id)
if call is None or call.course_id != current_course().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) .where(OptOut.course_id == course.id)
.order_by(OptOut.date, Student.sortable_name) .order_by(OptOut.date, Student.sortable_name)
).all() ).all()
# Withdrawn opt-outs are included: the export is the audit trail.
return _csv_response( return _csv_response(
"optouts.csv", "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, optout.date.isoformat(), student.name, student.email,
student.canvas_user_id, optout.created_at.isoformat(), student.canvas_user_id, optout.created_at.isoformat(),
optout.withdrawn_at.isoformat() if optout.withdrawn_at else "",
] ]
for optout, student in rows for optout, student in rows
], ],
@@ -380,11 +393,16 @@ def schedule_generate():
if (end - start).days > 366: if (end - start).days > 366:
abort(400, "Range longer than a year; check the dates.") 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)} existing = {d.date for d in class_days(db, course.id)}
day = start day = start
while day <= end: while day <= end:
if day.weekday() in chosen and day not in existing: 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) day += datetime.timedelta(days=1)
db.commit() db.commit()
return redirect(url_for(".schedule")) return redirect(url_for(".schedule"))
@@ -397,7 +415,13 @@ def schedule_add():
course = current_course() course = current_course()
day = _parse_date(request.form.get("date")) day = _parse_date(request.form.get("date"))
if not any(d.date == day for d in class_days(db, course.id)): 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() db.commit()
return redirect(url_for(".schedule")) return redirect(url_for(".schedule"))
@@ -506,5 +530,57 @@ def settings_save():
position=max((l.position for l in existing), default=-1) + 1, 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() db.commit()
return redirect(url_for(".settings")) return redirect(url_for(".settings"))

View File

@@ -26,6 +26,28 @@ def clean_custom_value(value):
return 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): def parse_custom_date(value):
"""Canvas date expansions arrive as ISO-ish timestamps; keep the """Canvas date expansions arrive as ISO-ish timestamps; keep the
date part and tolerate absence or junk.""" date part and tolerate absence or junk."""
@@ -54,6 +76,7 @@ class LaunchInfo:
resource_link_id: str | None resource_link_id: str | None
course_start: datetime.date | None course_start: datetime.date | None
course_end: datetime.date | None course_end: datetime.date | None
grading_scheme: str | None
is_instructor: bool is_instructor: bool
@@ -76,6 +99,7 @@ def extract_launch_info(launch_data):
resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"), resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"),
course_start=parse_custom_date(custom.get("course_start")), course_start=parse_custom_date(custom.get("course_start")),
course_end=parse_custom_date(custom.get("course_end")), 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, [])), 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 course.start_date = info.course_start
if info.course_end: if info.course_end:
course.end_date = info.course_end course.end_date = info.course_end
if info.grading_scheme:
course.canvas_grading_scheme = info.grading_scheme
student = db.execute( student = db.execute(
select(Student).where(Student.canvas_user_id == info.user_id) select(Student).where(Student.canvas_user_id == info.user_id)

View File

@@ -1,3 +1,5 @@
import json
from flask import Blueprint, abort, current_app, jsonify, redirect, session, url_for from flask import Blueprint, abort, current_app, jsonify, redirect, session, url_for
from pylti1p3.contrib.flask import ( from pylti1p3.contrib.flask import (
FlaskCookieService, FlaskCookieService,
@@ -18,6 +20,28 @@ from .roster import sync_roster
bp = Blueprint("lti", __name__, url_prefix="/lti") bp = Blueprint("lti", __name__, url_prefix="/lti")
NRPS_CLAIM = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice" 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): def nrps_members_for_course(course):
@@ -81,6 +105,10 @@ def launch():
nrps_data = launch_data.get(NRPS_CLAIM, {}) nrps_data = launch_data.get(NRPS_CLAIM, {})
if nrps_data.get("context_memberships_url"): if nrps_data.get("context_memberships_url"):
course.nrps_url = nrps_data["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 # Instructor launches refresh the roster, so adds and drops are
# picked up before every class without a separate sync step. # picked up before every class without a separate sync step.

View File

@@ -8,6 +8,7 @@ from sqlalchemy import (
ForeignKey, ForeignKey,
String, String,
Text, Text,
Time,
UniqueConstraint, UniqueConstraint,
func, func,
select, select,
@@ -74,6 +75,33 @@ class Course(Base):
) )
show_assessments: Mapped[bool] = mapped_column(Boolean, default=True) 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( created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow DateTime, default=utcnow
) )
@@ -166,6 +194,9 @@ class ClassDay(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id")) course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
date: Mapped[datetime.date] = mapped_column(Date) 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): class OptOut(Base):
@@ -179,6 +210,10 @@ class OptOut(Base):
created_at: Mapped[datetime.datetime] = mapped_column( created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow 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): class Call(Base):
@@ -205,6 +240,30 @@ class Call(Base):
return self.level.label if self.level else None 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): def answered_call_counts(session, course_id):
"""Number of answered calls per student, the input to selection """Number of answered calls per student, the input to selection
weighting. Skipped, pending, and missing calls do not count, matching 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): def students_present(session, course_id, on_date):
"""Active student-role enrollees minus those opted out for the date.""" """Active student-role enrollees minus those opted out for the date."""
opted_out = select(OptOut.student_id).where( 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( rows = session.execute(
select(Student) select(Student)

View File

@@ -66,7 +66,9 @@ def student_stats(db, course):
optout_dates = defaultdict(set) optout_dates = defaultdict(set)
for optout in db.execute( 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(): ).scalars():
optout_dates[optout.student_id].add(optout.date) optout_dates[optout.student_id].add(optout.date)

42
coldcall_lti/scales.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Participation report — {{ student.name }}{% endblock %}
{% block body %}
{% if is_instructor_view %}
<p class="no-print"><a href="{{ url_for('grades.view') }}">&larr; back to grades</a></p>
{% endif %}
<h1>Case discussion participation report</h1>
<p><strong>{{ student.name }}</strong></p>
<p>Participation grade: <strong>{{ final_display }}</strong></p>
<h2>When you were called, and how it was assessed</h2>
<table>
<tr><th>Date</th><th>Answered</th><th>Assessment</th></tr>
{% for c in calls %}
<tr>
<td>{{ c.session_date.isoformat() }}</td>
<td>{{ "yes" if c.status == "answered" else "no (not present)" }}</td>
<td>{{ c.assessment or "—" }}</td>
</tr>
{% endfor %}
</table>
<p>You answered <strong>{{ row.asked }}</strong> of the
<strong>{{ calls | length }}</strong> times your name was drawn.</p>
<h2>How your grade was computed</h2>
<ul>
<li><strong>Quality of your answers:</strong> {{ q_display }}
({{ row.q_points }} points), the average of the assessments above.</li>
<li><strong>Availability:</strong> you were available for
{{ results.n_days - row.days_missed }} of {{ results.n_days }}
class sessions (missed {{ row.days_missed }}).</li>
<li><strong>Participation missed:</strong> about
<strong>{{ row.foregone }} questions'</strong> 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.</li>
{% if row.caught > 0 %}
<li><strong>Opt-out form:</strong> {{ 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).</li>
{% else %}
<li><strong>Opt-out form:</strong> no deductions.</li>
{% endif %}
<li><strong>Final participation grade:</strong>
<strong>{{ final_display }}</strong>.</li>
</ul>
<p>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.</p>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Grades — {{ course.title or "Cold Call" }}{% endblock %}
{% block body %}
<p class="no-print"><a href="{{ url_for('instructor.home') }}">&larr; back</a> ·
<a href="{{ url_for('instructor.settings') }}">grading settings</a></p>
<h1>Participation grades</h1>
<form method="post" action="{{ url_for('grades.compute') }}" class="no-print">
<button type="submit">{{ "Recompute" if run else "Compute grades" }}</button>
{% if run %}
<span class="muted">last computed {{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC</span>
{% endif %}
</form>
{% if not results %}
<p class="muted">No grades computed yet. Computing runs the simulation
with the current settings; nothing is shown to students or sent
anywhere until you choose to.</p>
{% elif results.error %}
<p>{{ results.error }}</p>
{% else %}
<h2>Parameters</h2>
<table>
<tr><td>Class days</td><td>{{ results.n_days }}</td></tr>
<tr><td>Enrolled students</td><td>{{ results.n_students }}</td></tr>
<tr><td>Equal share of questions</td><td>{{ results.e_full }}</td></tr>
<tr><td>SD of full-attendance counts</td><td>{{ "%.2f" | format(results.sd_full) }}</td></tr>
<tr><td>Severity (points per question)</td><td>{{ "%.2f" | format(results.severity) }}</td></tr>
<tr><td>Allowance (questions)</td><td>{{ "%.2f" | format(results.allowance_q) }}
<span class="muted">= {{ results.params.allowance_sd_units }} SD</span></td></tr>
</table>
{% if results.excluded_students %}
<p class="muted">{{ results.excluded_calls }} call(s) involving
{{ results.excluded_students }} dropped (inactive) student(s) are
excluded from all of the above.</p>
{% endif %}
<p class="muted">Foregone-participation curve by days missed:
{% for v in results.curve %}{{ loop.index0 }}d: {{ "%.2f" | format(v) }}{{ " · " if not loop.last }}{% endfor %}
</p>
<h2>Grades</h2>
<table>
<tr>
<th>Student</th><th>Asked</th><th>Days missed</th><th>Quality</th>
<th>Foregone</th><th>Caught</th><th>Final (pts)</th><th>Grade</th><th></th>
</tr>
{% for r in results.students %}
<tr>
<td>{{ r.name }}</td>
<td>{{ r.asked }}</td>
<td>{{ r.days_missed or "" }}</td>
<td>{{ r.q_points if r.q_points is not none else "—" }}</td>
<td>{{ r.foregone or "" }}</td>
<td>{{ r.caught or "" }}</td>
<td>{{ r.final_points }}</td>
<td><strong>{{ display(r.final_points) }}</strong></td>
<td class="no-print">
<a href="{{ url_for('grades.student_report', student_id=r.student_id) }}">report</a>
{% if r.q_points is none %}<span class="muted">never answered</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
<p class="no-print">
<a href="{{ url_for('grades.export') }}">Export grades.csv</a>
{% if course.ags_enabled %}
· <a href="{{ url_for('grades.push_preview') }}">Review &amp; push to gradebook</a>
{% endif %}
</p>
{% endif %}
{% endblock %}

View File

@@ -11,6 +11,7 @@
<a href="{{ url_for('instructor.live') }}">Live cold call</a> · <a href="{{ url_for('instructor.live') }}">Live cold call</a> ·
<a href="{{ url_for('instructor.day_edit', date_str=today.isoformat()) }}">Today's calls</a> · <a href="{{ url_for('instructor.day_edit', date_str=today.isoformat()) }}">Today's calls</a> ·
<a href="{{ url_for('instructor.report') }}">Report</a> · <a href="{{ url_for('instructor.report') }}">Report</a> ·
<a href="{{ url_for('grades.view') }}">Grades</a> ·
<a href="{{ url_for('instructor.schedule') }}">Schedule</a> · <a href="{{ url_for('instructor.schedule') }}">Schedule</a> ·
<a href="{{ url_for('instructor.settings') }}">Settings</a> <a href="{{ url_for('instructor.settings') }}">Settings</a>
</p> </p>

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Push to gradebook — {{ course.title or "Cold Call" }}{% endblock %}
{% block body %}
<p><a href="{{ url_for('grades.view') }}">&larr; back to grades</a></p>
<h1>Review before pushing to the gradebook</h1>
<p>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.</p>
<table>
<tr><th>Student</th><th>Score to send</th><th>Out of</th></tr>
{% for r in rows %}
<tr>
<td>{{ r.row.name }}</td>
<td><strong>{{ r.score }}</strong></td>
<td>{{ r.maximum }}</td>
</tr>
{% endfor %}
</table>
<form method="post" action="{{ url_for('grades.push') }}">
<button type="submit" class="big">Push {{ rows | length }} grades to Canvas</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Push results — {{ course.title or "Cold Call" }}{% endblock %}
{% block body %}
<p><a href="{{ url_for('grades.view') }}">&larr; back to grades</a></p>
<h1>Gradebook push results</h1>
<table>
<tr><th>Student</th><th>Result</th></tr>
{% for o in outcomes %}
<tr>
<td>{{ o.name }}</td>
<td>{{ "✓" if o.ok else "FAILED:" }} {{ o.detail }}</td>
</tr>
{% endfor %}
</table>
<p>{{ outcomes | selectattr("ok") | list | length }} of
{{ outcomes | length }} pushed successfully.</p>
{% endblock %}

View File

@@ -5,7 +5,9 @@
<h1>Class schedule</h1> <h1>Class schedule</h1>
<p class="muted">When class days are listed here, the student opt-out <p class="muted">When class days are listed here, the student opt-out
form only offers these dates. With no days listed, students can pick form only offers these dates. With no days listed, students can pick
any date.</p> 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.</p>
{% set dmin = course.start_date.isoformat() if course.start_date else "" %} {% set dmin = course.start_date.isoformat() if course.start_date else "" %}
{% set dmax = course.end_date.isoformat() if course.end_date else "" %} {% set dmax = course.end_date.isoformat() if course.end_date else "" %}
@@ -15,6 +17,7 @@
value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"></label> value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"></label>
<label>to <input type="date" name="end" required <label>to <input type="date" name="end" required
value="{{ dmax }}" min="{{ dmin }}" max="{{ dmax }}"></label> value="{{ dmax }}" min="{{ dmin }}" max="{{ dmax }}"></label>
<label>starting at <input type="time" name="start_time"></label>
{% for i, name in weekdays %} {% for i, name in weekdays %}
<label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label> <label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label>
{% endfor %} {% endfor %}
@@ -25,6 +28,7 @@
<form method="post" action="{{ url_for('instructor.schedule_add') }}"> <form method="post" action="{{ url_for('instructor.schedule_add') }}">
<label>Date <input type="date" name="date" required <label>Date <input type="date" name="date" required
min="{{ dmin }}" max="{{ dmax }}"></label> min="{{ dmin }}" max="{{ dmax }}"></label>
<label>starting at <input type="time" name="start_time"></label>
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
@@ -34,7 +38,9 @@
{% for d in days %} {% for d in days %}
<tr> <tr>
<td {% if d.date < today %}class="muted"{% endif %}> <td {% if d.date < today %}class="muted"{% endif %}>
{{ d.date.strftime("%a %b %-d, %Y") }}</td> {{ d.date.strftime("%a %b %-d, %Y") }}
{% if d.start_time %}<span class="muted">{{ d.start_time.strftime("%H:%M") }}</span>{% endif %}
</td>
<td> <td>
<form method="post" <form method="post"
action="{{ url_for('instructor.schedule_delete', day_id=d.id) }}"> action="{{ url_for('instructor.schedule_delete', day_id=d.id) }}">

View File

@@ -63,6 +63,81 @@
</tr> </tr>
</table> </table>
<h2>Grading</h2>
<p class="muted">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.</p>
<p>
<label>Allowance (SD units):
<input type="number" name="allowance_sd_units" step="0.05" min="0"
value="{{ course.allowance_sd_units }}"></label>
<span class="muted">Foregone participation forgiven before any
deduction, in units of the draw's natural spread.</span>
</p>
<p>
<label>Passing line (points):
<input type="number" name="passing_points" step="0.01" min="0" max="100"
value="{{ course.passing_points }}"></label>
<span class="muted">Forgoing one SD of participation lands a
perfect-quality student here (65.15 = UW 1.7).</span>
</p>
<p>
<label>Form-filing penalty (points):
<input type="number" name="form_penalty_points" step="0.01" min="0" max="100"
value="{{ course.form_penalty_points }}"></label>
<span class="muted">Per day called while absent with no opt-out
filed (4.55 = UW 0.3).</span>
</p>
<p>
<label>Simulation iterations:
<input type="number" name="n_sims" min="100" max="100000"
value="{{ course.n_sims }}"></label>
<label>Seed:
<input type="number" name="sim_seed" value="{{ course.sim_seed }}"></label>
</p>
<h2>Grade display scale</h2>
<p>
<label>Scale:
<select name="scale_type">
<option value="uw4" {{ "selected" if course.scale_type == "uw4" }}>UW 4.0 (linear)</option>
<option value="table" {{ "selected" if course.scale_type == "table" }}>threshold table</option>
<option value="none" {{ "selected" if course.scale_type == "none" }}>points only</option>
</select>
</label>
</p>
<p>
<label>Threshold rows (one per line: minimum points, then label):<br>
<textarea name="scale_rows" rows="6" cols="24"
>{% if course.scale_config %}{% for min_points, label in course.scale_config | fromjson %}{{ min_points }} {{ label }}
{% endfor %}{% endif %}</textarea></label>
</p>
<p>
<label><input type="checkbox" name="publish_grade_reports"
{{ "checked" if course.publish_grade_reports }}>
Publish grade reports to students (adds a report to each
student's page once grades are computed)</label>
</p>
<p>
<label><input type="checkbox" name="ags_enabled"
{{ "checked" if course.ags_enabled }}>
Enable gradebook passback (adds a review-then-push page; nothing
is ever sent without explicit confirmation)</label>
</p>
<p><button type="submit">Save</button></p> <p><button type="submit">Save</button></p>
</form> </form>
{% if course.canvas_grading_scheme %}
<form method="post" action="{{ url_for('instructor.settings_import_scale') }}">
<p>
<button type="submit">Use the Canvas grading scheme</button>
<span class="muted">Copies this course's grading scheme from Canvas
({{ course.canvas_grading_scheme | fromjson | length }} levels)
into the threshold table.</span>
</p>
</form>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -7,6 +7,11 @@
they are not right, update them in your Canvas account settings and they are not right, update them in your Canvas account settings and
they will be picked up here automatically.</p> they will be picked up here automatically.</p>
{% if report_available %}
<p><a href="{{ url_for('views.my_grade_report') }}"><strong>Your
participation grade report</strong></a></p>
{% endif %}
<h2>Your standing</h2> <h2>Your standing</h2>
<p> <p>
You have answered <strong>{{ answered }}</strong> You have answered <strong>{{ answered }}</strong>
@@ -47,22 +52,23 @@
{% if optouts %} {% if optouts %}
<table> <table>
<tr><th>Date</th><th></th></tr> <tr><th>Date</th><th></th></tr>
{% for o in optouts %} {% for o, withdrawable in optouts %}
<tr> <tr>
<td>{{ o.date.isoformat() }}</td> <td>{{ o.date.isoformat() }}</td>
<td> <td>
{% if o.date >= today %} {% if withdrawable %}
<form method="post" <form method="post"
action="{{ url_for('views.optout_delete', optout_id=o.id) }}"> action="{{ url_for('views.optout_delete', optout_id=o.id) }}">
<button type="submit">Withdraw</button> <button type="submit">Withdraw</button>
</form> </form>
{% else %} {% else %}
<span class="muted">past</span> <span class="muted">locked</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
<p class="muted">Opt-outs can be withdrawn until the class begins.</p>
{% else %} {% else %}
<p class="muted">You have not opted out of any classes.</p> <p class="muted">You have not opted out of any classes.</p>
{% endif %} {% endif %}

View File

@@ -16,7 +16,9 @@ from .models import (
Student, Student,
answered_call_counts, answered_call_counts,
class_days, class_days,
class_has_begun,
is_class_day, is_class_day,
latest_grade_run,
) )
bp = Blueprint("views", __name__) bp = Blueprint("views", __name__)
@@ -119,17 +121,22 @@ def student_home():
db.execute( db.execute(
select(OptOut) select(OptOut)
.where( .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) .order_by(OptOut.date)
) )
.scalars() .scalars()
.all() .all()
) )
optouts = [
(o, not class_has_begun(db, course.id, o.date)) for o in optouts
]
upcoming_days = [ upcoming_days = [
d.date d.date
for d in class_days(db, course.id, start=today) 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( return render_template(
@@ -143,6 +150,34 @@ def student_home():
optouts=optouts, optouts=optouts,
upcoming_days=upcoming_days, upcoming_days=upcoming_days,
distribution=_class_call_distribution(db, course, user.id), 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: if exists is None:
db.add(OptOut(course_id=course.id, student_id=user.id, date=day)) db.add(OptOut(course_id=course.id, student_id=user.id, date=day))
db.commit() 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")) return redirect(url_for(".student_home"))
@@ -184,10 +224,11 @@ def optout_delete(optout_id):
optout is None optout is None
or optout.course_id != current_course().id or optout.course_id != current_course().id
or optout.student_id != current_user().id or optout.student_id != current_user().id
or optout.withdrawn_at is not None
): ):
abort(404) abort(404)
if optout.date < datetime.date.today(): if class_has_begun(db, optout.course_id, optout.date):
abort(400, "Past opt-outs cannot be withdrawn.") abort(400, "Opt-outs cannot be withdrawn once class has begun.")
db.delete(optout) optout.withdrawn_at = datetime.datetime.now(datetime.UTC)
db.commit() db.commit()
return redirect(url_for(".student_home")) return redirect(url_for(".student_home"))

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

123
tests/test_grades_ui.py Normal file
View File

@@ -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 "<strong>A</strong>" in page

238
tests/test_grading.py Normal file
View File

@@ -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)

View File

@@ -88,6 +88,73 @@ def test_students_cannot_touch_others_optouts(student):
assert student.post(f"/me/optout/{optout_id}/delete").status_code == 404 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): def test_assessment_visibility_setting(student):
# Record an answered call for dev-student-1 as the instructor. # Record an answered call for dev-student-1 as the instructor.
student.post("/dev/launch/dev-instructor") student.post("/dev/launch/dev-instructor")