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

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