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