1
0

Phase 5: reporting, exports, pronouns, and roster freshness

Instructor participation report: per-student histograms, outcome mix
by class day, and a sortable table including the fairness ratio
(answered calls over questions present for, with opt-out days out of
the denominator), plus CSV exports of students, calls, and opt-outs.

Assessment scales are now per-course data: ordered levels with labels
and points out of 100 (defaults carry the old R grading values), with
calls referencing levels by id so renames follow through to history.
Renaming, re-pointing, reordering, and adding levels are always
allowed; deleting a level in use by recorded calls is blocked.

Pronouns and course term dates come from Canvas custom variable
substitutions, at launch and roster-wide via rlid-scoped NRPS; the
student page notes that names/pronouns are Canvas-sourced. Rosters
can also be refreshed outside launches: a "Sync roster now" button
and a sync-rosters CLI command for an hourly cron job, skipping ended
courses. Alembic now runs SQLite-compatible batch migrations with a
constraint naming convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 17:05:34 -07:00
parent 7845167ec5
commit 7ec1be5dc6
32 changed files with 1274 additions and 44 deletions

View File

@@ -32,8 +32,16 @@ STATUS_SKIPPED = "skipped"
STATUS_PENDING = "pending"
CALL_STATUSES = (STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, STATUS_PENDING)
# Default assessment vocabulary, carried over from the manual system.
ASSESSMENTS = ("GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER")
# Default assessment vocabulary and points, carried over from the
# manual system: compute_final_case_grades.R scored GOOD=100,
# SATISFACTORY and POOR one and two UW-4.0-scale steps down
# (100 - n*50/(4-0.7)), and NO MEANINGFUL ANSWER zero.
DEFAULT_ASSESSMENT_LEVELS = (
("GOOD", 100.0),
("SATISFACTORY", 84.85),
("POOR", 69.7),
("NO MEANINGFUL ANSWER", 0.0),
)
SELECTION_WEIGHTED = "weighted"
SELECTION_CYCLE = "cycle"
@@ -47,6 +55,18 @@ class Course(Base):
lti_deployment_id: Mapped[str | None] = mapped_column(String(255))
title: Mapped[str | None] = mapped_column(String(255))
# Enough platform detail to call NRPS outside a launch, for the
# on-demand roster refresh. Captured/updated on each launch.
lti_issuer: Mapped[str | None] = mapped_column(String(255))
lti_client_id: Mapped[str | None] = mapped_column(String(255))
nrps_url: Mapped[str | None] = mapped_column(String(1024))
# Course term dates from Canvas ($Canvas.course.startAt/endAt
# custom parameters), used to bound date pickers. Nullable: not
# every course has them set in Canvas.
start_date: Mapped[datetime.date | None] = mapped_column(Date)
end_date: Mapped[datetime.date | None] = mapped_column(Date)
# Per-course settings.
weight_factor: Mapped[float] = mapped_column(Float, default=2.0)
selection_mode: Mapped[str] = mapped_column(
@@ -70,6 +90,7 @@ class Student(Base):
name: Mapped[str | None] = mapped_column(String(255))
sortable_name: Mapped[str | None] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255))
pronouns: Mapped[str | None] = mapped_column(String(64))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
@@ -90,6 +111,50 @@ class Enrollment(Base):
student: Mapped[Student] = relationship(back_populates="enrollments")
class AssessmentLevel(Base):
"""One rung of a course's assessment scale: a label and its points
out of 100. Calls reference levels by id, so renaming a level
renames it everywhere, past calls included."""
__tablename__ = "assessment_levels"
__table_args__ = (UniqueConstraint("course_id", "label"),)
id: Mapped[int] = mapped_column(primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
label: Mapped[str] = mapped_column(String(64))
points: Mapped[float] = mapped_column(Float)
position: Mapped[int] = mapped_column(default=0)
def ensure_default_levels(session, course):
existing = session.execute(
select(AssessmentLevel.id)
.where(AssessmentLevel.course_id == course.id)
.limit(1)
).first()
if existing:
return
for i, (label, points) in enumerate(DEFAULT_ASSESSMENT_LEVELS):
session.add(
AssessmentLevel(
course_id=course.id, label=label, points=points, position=i
)
)
session.flush()
def assessment_levels(session, course_id):
return (
session.execute(
select(AssessmentLevel)
.where(AssessmentLevel.course_id == course_id)
.order_by(AssessmentLevel.position, AssessmentLevel.id)
)
.scalars()
.all()
)
class ClassDay(Base):
"""A day the class actually meets. When a course has these, the
student opt-out form only offers real class days; without them it
@@ -124,13 +189,20 @@ class Call(Base):
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
session_date: Mapped[datetime.date] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING)
assessment: Mapped[str | None] = mapped_column(String(32))
assessment_id: Mapped[int | None] = mapped_column(
ForeignKey("assessment_levels.id")
)
note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow
)
student: Mapped[Student] = relationship()
level: Mapped["AssessmentLevel | None"] = relationship()
@property
def assessment(self):
return self.level.label if self.level else None
def answered_call_counts(session, course_id):