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

+ + + {% for c in calls %} + + + + + + {% endfor %} +
DateAnsweredAssessment
{{ c.session_date.isoformat() }}{{ "yes" if c.status == "answered" else "no (not present)" }}{{ c.assessment or "—" }}
+

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 run %} + last computed {{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC + {% endif %} +
+ +{% 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

+ + + + + + {% for r in results.students %} + + + + + + + + + + + + {% endfor %} +
StudentAskedDays missedQualityForegoneCaughtFinal (pts)Grade
{{ 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 %} +
+ +

+ 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.

+ + + {% for r in rows %} + + + + + + {% endfor %} +
StudentScore to sendOut of
{{ r.row.name }}{{ r.score }}{{ r.maximum }}
+
+ +
+{% 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

+ + + {% for o in outcomes %} + + + + + {% endfor %} +
StudentResult
{{ o.name }}{{ "✓" if o.ok else "FAILED:" }} {{ o.detail }}
+

{{ 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 }}"> + {% for i, name in weekdays %} {% 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 %} +
diff --git a/coldcall_lti/templates/settings.html b/coldcall_lti/templates/settings.html index 59162cd..9f28ed8 100644 --- a/coldcall_lti/templates/settings.html +++ b/coldcall_lti/templates/settings.html @@ -63,6 +63,81 @@ +

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.

+

+ + Foregone participation forgiven before any + deduction, in units of the draw's natural spread. +

+

+ + Forgoing one SD of participation lands a + perfect-quality student here (65.15 = UW 1.7). +

+

+ + Per day called while absent with no opt-out + filed (4.55 = UW 0.3). +

+

+ + +

+ +

Grade display scale

+

+ +

+

+ +

+ +

+ +

+

+ +

+

+ +{% if course.canvas_grading_scheme %} +
+

+ + Copies this course's grading scheme from Canvas + ({{ course.canvas_grading_scheme | fromjson | length }} levels) + into the threshold table. +

+
+{% 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 %} - {% for o in optouts %} + {% for o, withdrawable in optouts %} {% endfor %}
Date
{{ o.date.isoformat() }} - {% if o.date >= today %} + {% if withdrawable %}
{% else %} - past + locked {% endif %}
+

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