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

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