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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
instance/
|
||||||
|
*.egg-info/
|
||||||
|
.claude/
|
||||||
65
README.md
Normal file
65
README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# coldcall-lti
|
||||||
|
|
||||||
|
A Canvas external tool (LTI 1.3) for managing cold calls in case-based
|
||||||
|
classes: it selects students to call using weighted randomness, records
|
||||||
|
what happened with each call, lets students report planned absences, and
|
||||||
|
reports participation data back to both instructor and students. It
|
||||||
|
replaces a manual workflow built on exported rosters, Google Forms, and
|
||||||
|
local scripts.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The tool is a Flask application that Canvas launches over LTI 1.3. A
|
||||||
|
single URL serves everyone: Canvas identifies the person and course on
|
||||||
|
each launch, so instructors get the call-list and reporting views while
|
||||||
|
students get the absence form and their own history. The roster comes
|
||||||
|
from Canvas through the Names and Role Provisioning Service, which means
|
||||||
|
adds and drops are picked up automatically rather than reconciled by
|
||||||
|
hand.
|
||||||
|
|
||||||
|
Selection uses the same weighting as the manual system it replaces: each
|
||||||
|
answered call divides a student's weight by the course's weight factor
|
||||||
|
(default 2), so students who have answered more questions become
|
||||||
|
progressively less likely to be called. Each course can instead use
|
||||||
|
"cycle" mode, which shuffles the roster and calls everyone exactly once.
|
||||||
|
These, along with whether students can see their own assessments, are
|
||||||
|
per-course settings.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `coldcall_lti/models.py` — SQLAlchemy models: courses (with their
|
||||||
|
settings), students, enrollments, opt-outs, and calls, plus the query
|
||||||
|
helpers that feed selection.
|
||||||
|
- `coldcall_lti/selection.py` — the weighted and cycle selection logic.
|
||||||
|
Kept free of database and web dependencies so it can be tested and
|
||||||
|
reasoned about on its own.
|
||||||
|
- `coldcall_lti/__init__.py` — the Flask application factory.
|
||||||
|
- `migrations/` — alembic migrations. The schema avoids
|
||||||
|
database-specific types so the same migrations run on SQLite (the
|
||||||
|
default) and MariaDB/MySQL; switching is a matter of changing
|
||||||
|
`COLDCALL_DATABASE_URL`.
|
||||||
|
- `tests/` — pytest suite covering selection behavior and the model
|
||||||
|
helpers.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Development uses a virtualenv that shares the system's Debian-packaged
|
||||||
|
libraries (Flask, SQLAlchemy, alembic, pytest) and adds the one
|
||||||
|
PyPI-only dependency, the maintained `pylti1p3next` fork of PyLTI1p3:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m venv --system-site-packages .venv
|
||||||
|
.venv/bin/pip install pylti1p3next
|
||||||
|
.venv/bin/pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the database and run the tests:
|
||||||
|
|
||||||
|
```
|
||||||
|
.venv/bin/python -m alembic upgrade head
|
||||||
|
.venv/bin/python -m pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration is by environment variable: `COLDCALL_DATABASE_URL` (any
|
||||||
|
SQLAlchemy URL; defaults to an SQLite file under `instance/`) and
|
||||||
|
`COLDCALL_SECRET_KEY` for Flask sessions.
|
||||||
116
alembic.ini
Normal file
116
alembic.ini
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||||
|
script_location = migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to migrations/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = sqlite:///instance/coldcall.sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
22
coldcall_lti/__init__.py
Normal file
22
coldcall_lti/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .db import make_engine, make_session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config=Config):
|
||||||
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
|
app.config.from_object(config)
|
||||||
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
|
engine = make_engine(app.config["DATABASE_URL"])
|
||||||
|
app.extensions["db_engine"] = engine
|
||||||
|
app.extensions["db_session_factory"] = make_session_factory(engine)
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
return app
|
||||||
8
coldcall_lti/config.py
Normal file
8
coldcall_lti/config.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get("COLDCALL_SECRET_KEY", "dev-only-not-secret")
|
||||||
|
DATABASE_URL = os.environ.get(
|
||||||
|
"COLDCALL_DATABASE_URL", "sqlite:///instance/coldcall.sqlite3"
|
||||||
|
)
|
||||||
14
coldcall_lti/db.py
Normal file
14
coldcall_lti/db.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine(url, echo=False):
|
||||||
|
return create_engine(url, echo=echo)
|
||||||
|
|
||||||
|
|
||||||
|
def make_session_factory(engine):
|
||||||
|
return sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
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)
|
||||||
42
coldcall_lti/selection.py
Normal file
42
coldcall_lti/selection.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Cold-call selection logic.
|
||||||
|
|
||||||
|
Ported from the manual system's coldcall.py: each answered call a
|
||||||
|
student has already received divides their selection weight by the
|
||||||
|
course's weight factor, so with the default factor of 2 a student who
|
||||||
|
has answered n questions is 2^n times less likely to be picked than one
|
||||||
|
who has answered none. A factor of 1 makes selection uniform.
|
||||||
|
|
||||||
|
The alternative "cycle" mode shuffles the present students and calls
|
||||||
|
each exactly once, the equivalent of the old --shuffle flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
def compute_weights(student_ids, prev_counts, weight_factor=2.0):
|
||||||
|
return {
|
||||||
|
s: weight_factor ** -prev_counts.get(s, 0) for s in student_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def select_student(student_ids, prev_counts, weight_factor=2.0, rng=random):
|
||||||
|
weights = compute_weights(student_ids, prev_counts, weight_factor)
|
||||||
|
return rng.choices(list(weights), weights=list(weights.values()), k=1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_call_list(student_ids, prev_counts, n, weight_factor=2.0, rng=random):
|
||||||
|
"""A list of n weighted picks. Counts update as the list is built,
|
||||||
|
so a student picked early is downweighted for later slots, matching
|
||||||
|
the old manual script's behavior of recording each call before
|
||||||
|
drawing the next."""
|
||||||
|
counts = dict(prev_counts)
|
||||||
|
picks = []
|
||||||
|
for _ in range(n):
|
||||||
|
s = select_student(student_ids, counts, weight_factor, rng)
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
picks.append(s)
|
||||||
|
return picks
|
||||||
|
|
||||||
|
|
||||||
|
def generate_cycle_list(student_ids, rng=random):
|
||||||
|
return rng.sample(list(student_ids), len(student_ids))
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
83
migrations/env.py
Normal file
83
migrations/env.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from coldcall_lti.db import Base
|
||||||
|
from coldcall_lti import models # noqa: F401 (populates Base.metadata)
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# The database URL comes from the same environment variable the app
|
||||||
|
# uses, falling back to the value in alembic.ini.
|
||||||
|
if os.environ.get("COLDCALL_DATABASE_URL"):
|
||||||
|
config.set_main_option("sqlalchemy.url", os.environ["COLDCALL_DATABASE_URL"])
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
migrations/script.py.mako
Normal file
26
migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
90
migrations/versions/ad7e3f5ea2f3_initial_schema.py
Normal file
90
migrations/versions/ad7e3f5ea2f3_initial_schema.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: ad7e3f5ea2f3
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-31 16:23:01.164289
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ad7e3f5ea2f3'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('courses',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('lti_context_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('lti_deployment_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('weight_factor', sa.Float(), nullable=False),
|
||||||
|
sa.Column('selection_mode', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('show_assessments', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('lti_context_id')
|
||||||
|
)
|
||||||
|
op.create_table('students',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('canvas_user_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('sortable_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('avatar_url', sa.String(length=1024), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('canvas_user_id')
|
||||||
|
)
|
||||||
|
op.create_table('calls',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('student_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('session_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('assessment', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('note', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['student_id'], ['students.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('enrollments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('student_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('active', sa.Boolean(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['student_id'], ['students.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('course_id', 'student_id')
|
||||||
|
)
|
||||||
|
op.create_table('optouts',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('student_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['student_id'], ['students.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('course_id', 'student_id', 'date')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('optouts')
|
||||||
|
op.drop_table('enrollments')
|
||||||
|
op.drop_table('calls')
|
||||||
|
op.drop_table('students')
|
||||||
|
op.drop_table('courses')
|
||||||
|
# ### end Alembic commands ###
|
||||||
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=64"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "coldcall-lti"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Canvas LTI 1.3 tool for managing in-class cold calls"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
# Known-good versions: flask 3.1.1, SQLAlchemy 2.0.40, alembic 1.13.2,
|
||||||
|
# pylti1p3next 2.0.2 (development uses Debian system packages for all
|
||||||
|
# but pylti1p3next; these floors record what the code was written against).
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.1",
|
||||||
|
"pylti1p3next>=2.0",
|
||||||
|
"SQLAlchemy>=2.0",
|
||||||
|
"alembic>=1.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pytest>=8"]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["coldcall_lti"]
|
||||||
15
tests/conftest.py
Normal file
15
tests/conftest.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from coldcall_lti.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session():
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
session = sessionmaker(bind=engine)()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
87
tests/test_models.py
Normal file
87
tests/test_models.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from coldcall_lti import models
|
||||||
|
|
||||||
|
|
||||||
|
def make_course_with_students(session, n=3):
|
||||||
|
course = models.Course(lti_context_id="ctx-1", title="Test Course")
|
||||||
|
session.add(course)
|
||||||
|
students = []
|
||||||
|
for i in range(n):
|
||||||
|
s = models.Student(canvas_user_id=f"user-{i}", name=f"Student {i}")
|
||||||
|
session.add(s)
|
||||||
|
students.append(s)
|
||||||
|
session.flush()
|
||||||
|
for s in students:
|
||||||
|
session.add(models.Enrollment(course_id=course.id, student_id=s.id))
|
||||||
|
session.flush()
|
||||||
|
return course, students
|
||||||
|
|
||||||
|
|
||||||
|
def test_answered_call_counts_ignores_skipped_and_pending(db_session):
|
||||||
|
course, students = make_course_with_students(db_session)
|
||||||
|
day = datetime.date(2026, 10, 1)
|
||||||
|
a, b, c = students
|
||||||
|
for status, student in [
|
||||||
|
(models.STATUS_ANSWERED, a),
|
||||||
|
(models.STATUS_ANSWERED, a),
|
||||||
|
(models.STATUS_SKIPPED, a),
|
||||||
|
(models.STATUS_ANSWERED, b),
|
||||||
|
(models.STATUS_MISSING, b),
|
||||||
|
(models.STATUS_PENDING, c),
|
||||||
|
]:
|
||||||
|
db_session.add(
|
||||||
|
models.Call(
|
||||||
|
course_id=course.id,
|
||||||
|
student_id=student.id,
|
||||||
|
session_date=day,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
counts = models.answered_call_counts(db_session, course.id)
|
||||||
|
assert counts == {a.id: 2, b.id: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_students_present_excludes_optouts_and_inactive(db_session):
|
||||||
|
course, students = make_course_with_students(db_session)
|
||||||
|
day = datetime.date(2026, 10, 1)
|
||||||
|
a, b, c = students
|
||||||
|
|
||||||
|
db_session.add(
|
||||||
|
models.OptOut(course_id=course.id, student_id=a.id, date=day)
|
||||||
|
)
|
||||||
|
enrollment_b = (
|
||||||
|
db_session.query(models.Enrollment)
|
||||||
|
.filter_by(course_id=course.id, student_id=b.id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
enrollment_b.active = False
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
present = models.students_present(db_session, course.id, day)
|
||||||
|
assert [s.id for s in present] == [c.id]
|
||||||
|
|
||||||
|
# The opt-out only applies on its own date.
|
||||||
|
other_day = datetime.date(2026, 10, 2)
|
||||||
|
present = models.students_present(db_session, course.id, other_day)
|
||||||
|
assert {s.id for s in present} == {a.id, c.id}
|
||||||
|
|
||||||
|
|
||||||
|
def test_students_present_excludes_instructor_role(db_session):
|
||||||
|
course, students = make_course_with_students(db_session)
|
||||||
|
instructor = models.Student(canvas_user_id="teacher-1", name="Teacher")
|
||||||
|
db_session.add(instructor)
|
||||||
|
db_session.flush()
|
||||||
|
db_session.add(
|
||||||
|
models.Enrollment(
|
||||||
|
course_id=course.id, student_id=instructor.id, role="instructor"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
present = models.students_present(
|
||||||
|
db_session, course.id, datetime.date(2026, 10, 1)
|
||||||
|
)
|
||||||
|
assert instructor.id not in {s.id for s in present}
|
||||||
45
tests/test_selection.py
Normal file
45
tests/test_selection.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import random
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from coldcall_lti import selection
|
||||||
|
|
||||||
|
|
||||||
|
STUDENTS = ["a", "b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_weights_halve_per_answered_call():
|
||||||
|
weights = selection.compute_weights(STUDENTS, {"a": 0, "b": 1, "c": 3}, 2.0)
|
||||||
|
assert weights == {"a": 1.0, "b": 0.5, "c": 0.125, "d": 1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_weight_factor_one_is_uniform():
|
||||||
|
weights = selection.compute_weights(STUDENTS, {"a": 5, "b": 2}, 1.0)
|
||||||
|
assert set(weights.values()) == {1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_heavily_called_student_selected_less():
|
||||||
|
rng = random.Random(42)
|
||||||
|
counts = Counter(
|
||||||
|
selection.select_student(STUDENTS, {"a": 4}, 2.0, rng)
|
||||||
|
for _ in range(4000)
|
||||||
|
)
|
||||||
|
# "a" has weight 1/16 against 1 for the others, so it should get
|
||||||
|
# roughly 1/49th of the picks; the others roughly a third each.
|
||||||
|
assert counts["a"] < 250
|
||||||
|
for s in ("b", "c", "d"):
|
||||||
|
assert 1000 < counts[s] < 1700
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_call_list_downweights_within_list():
|
||||||
|
rng = random.Random(7)
|
||||||
|
picks = selection.generate_call_list(STUDENTS, {}, 200, 2.0, rng)
|
||||||
|
assert len(picks) == 200
|
||||||
|
counts = Counter(picks)
|
||||||
|
# Sequential downweighting keeps the distribution close to even.
|
||||||
|
assert all(35 < counts[s] < 65 for s in STUDENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_cycle_list_covers_everyone_once():
|
||||||
|
rng = random.Random(1)
|
||||||
|
picks = selection.generate_cycle_list(STUDENTS, rng)
|
||||||
|
assert sorted(picks) == sorted(STUDENTS)
|
||||||
Reference in New Issue
Block a user