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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user