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

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