Phase 1: scaffold, data models, selection logic, tests
Flask application skeleton with SQLAlchemy models for courses (including per-course settings), students, enrollments, opt-outs, and calls; the weighted and cycle selection logic ported from the manual coldcall scripts; alembic migrations; and a pytest suite covering selection behavior and the model query helpers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
150
coldcall_lti/models.py
Normal file
150
coldcall_lti/models.py
Normal file
@@ -0,0 +1,150 @@
|
||||
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 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 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)
|
||||
Reference in New Issue
Block a user