Student page with a standing summary, a class-comparison histogram (zero-dependency HTML/CSS) with a plain-language fewer/same/more sentence, opt-out management with withdrawal of future dates, and a call history that respects the per-course assessment-visibility setting and never shows pending or skipped calls. Opt-outs validate against a new per-course class-day schedule (range generator plus individual add/remove for holidays), since Canvas has no structured meeting-day data; courses without a schedule fall back to a free date picker. Dev mode seeds a Tue/Thu pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.4 KiB
Python
189 lines
6.4 KiB
Python
import datetime
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Date,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
select,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from .db import Base
|
|
|
|
|
|
def utcnow():
|
|
return datetime.datetime.now(datetime.UTC)
|
|
|
|
|
|
# Call outcomes. ANSWERED is the only status that counts toward a
|
|
# student's selection weight; SKIPPED calls are treated as if they never
|
|
# happened; MISSING means the student was called but absent without an
|
|
# opt-out. PENDING is used for pre-generated (printable) lists whose
|
|
# outcomes are recorded after class.
|
|
STATUS_ANSWERED = "answered"
|
|
STATUS_MISSING = "missing"
|
|
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")
|
|
|
|
SELECTION_WEIGHTED = "weighted"
|
|
SELECTION_CYCLE = "cycle"
|
|
|
|
|
|
class Course(Base):
|
|
__tablename__ = "courses"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
lti_context_id: Mapped[str] = mapped_column(String(255), unique=True)
|
|
lti_deployment_id: Mapped[str | None] = mapped_column(String(255))
|
|
title: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
# Per-course settings.
|
|
weight_factor: Mapped[float] = mapped_column(Float, default=2.0)
|
|
selection_mode: Mapped[str] = mapped_column(
|
|
String(16), default=SELECTION_WEIGHTED
|
|
)
|
|
show_assessments: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
|
|
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
DateTime, default=utcnow
|
|
)
|
|
|
|
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="course")
|
|
|
|
|
|
class Student(Base):
|
|
__tablename__ = "students"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
# The LTI "sub" claim: stable, opaque, unique per Canvas user.
|
|
canvas_user_id: Mapped[str] = mapped_column(String(255), unique=True)
|
|
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))
|
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
|
|
|
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
|
|
|
|
|
|
class Enrollment(Base):
|
|
__tablename__ = "enrollments"
|
|
__table_args__ = (UniqueConstraint("course_id", "student_id"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
|
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
|
|
role: Mapped[str] = mapped_column(String(32), default="student")
|
|
# Set False when a student disappears from the NRPS roster (dropped).
|
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
|
|
course: Mapped[Course] = relationship(back_populates="enrollments")
|
|
student: Mapped[Student] = relationship(back_populates="enrollments")
|
|
|
|
|
|
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
|
|
falls back to a free date picker."""
|
|
|
|
__tablename__ = "class_days"
|
|
__table_args__ = (UniqueConstraint("course_id", "date"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
|
date: Mapped[datetime.date] = mapped_column(Date)
|
|
|
|
|
|
class OptOut(Base):
|
|
__tablename__ = "optouts"
|
|
__table_args__ = (UniqueConstraint("course_id", "student_id", "date"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
|
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
|
|
date: Mapped[datetime.date] = mapped_column(Date)
|
|
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
DateTime, default=utcnow
|
|
)
|
|
|
|
|
|
class Call(Base):
|
|
__tablename__ = "calls"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
|
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))
|
|
note: Mapped[str | None] = mapped_column(Text)
|
|
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
DateTime, default=utcnow
|
|
)
|
|
|
|
student: Mapped[Student] = relationship()
|
|
|
|
|
|
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
|
|
the old system where only answered calls reduced a student's odds."""
|
|
rows = session.execute(
|
|
select(Call.student_id, func.count())
|
|
.where(Call.course_id == course_id, Call.status == STATUS_ANSWERED)
|
|
.group_by(Call.student_id)
|
|
).all()
|
|
return dict(rows)
|
|
|
|
|
|
def class_days(session, course_id, start=None, end=None):
|
|
q = select(ClassDay).where(ClassDay.course_id == course_id)
|
|
if start is not None:
|
|
q = q.where(ClassDay.date >= start)
|
|
if end is not None:
|
|
q = q.where(ClassDay.date <= end)
|
|
return session.execute(q.order_by(ClassDay.date)).scalars().all()
|
|
|
|
|
|
def is_class_day(session, course_id, on_date):
|
|
"""True if on_date is a listed class day, or if the course has no
|
|
class days defined at all (no schedule means no restriction)."""
|
|
if not session.execute(
|
|
select(ClassDay.id).where(ClassDay.course_id == course_id).limit(1)
|
|
).first():
|
|
return True
|
|
return bool(
|
|
session.execute(
|
|
select(ClassDay.id).where(
|
|
ClassDay.course_id == course_id, ClassDay.date == on_date
|
|
)
|
|
).first()
|
|
)
|
|
|
|
|
|
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
|
|
)
|
|
rows = session.execute(
|
|
select(Student)
|
|
.join(Enrollment, Enrollment.student_id == Student.id)
|
|
.where(
|
|
Enrollment.course_id == course_id,
|
|
Enrollment.role == "student",
|
|
Enrollment.active.is_(True),
|
|
Student.id.not_in(opted_out),
|
|
)
|
|
).scalars()
|
|
return list(rows)
|