The tool targets any LTI 1.3 platform; Canvas is the primary target but nothing outside the Canvas-specific custom variable substitutions depends on it. User-facing strings and the README now say "LMS" except where a mechanism genuinely is Canvas's (the $Canvas.* / com.instructure.* substitutions and their handling), and the README states plainly that the tool has so far been exercised only against the saltire emulator, not yet a production LMS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
10 KiB
Markdown
240 lines
10 KiB
Markdown
# coldcall-lti
|
|
|
|
An LTI 1.3 external tool 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. It should work with any LMS that implements LTI 1.3
|
|
(LTI Advantage); Canvas is the primary target and the platform the
|
|
Canvas-specific conveniences below are written for. So far it has been
|
|
exercised only against the saltire LTI emulator (see
|
|
`docs/SALTIRE.md`) — not yet against any production LMS.
|
|
|
|
## How it works
|
|
|
|
The tool is a Flask application that the LMS launches over LTI 1.3. A
|
|
single URL serves everyone: the LMS 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 the LMS 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, a rolling rotation through the roster in random
|
|
order: everyone is called once before anyone repeats, batches take the
|
|
next students in the current pass (continuing across lists and class
|
|
days), and skipped calls don't count as a turn. These, along with
|
|
whether students can see their own assessments, are per-course
|
|
settings.
|
|
|
|
## Daily use
|
|
|
|
The instructor page works against a selectable date (defaulting to
|
|
today), so printing tomorrow's list a day in advance just means
|
|
switching the working date. There are two ways to run a class, per day
|
|
and freely mixed:
|
|
|
|
- **Live mode**: a "call next student" button shows who's up (photo,
|
|
name, pronouns) with one-tap outcomes — the assessment levels,
|
|
missing (absent without an opt-out), or skip (as if the call never
|
|
happened).
|
|
- **Printed list**: generate a numbered list of any length, print it,
|
|
and mark it up on paper; after class, enter the outcomes in the day
|
|
editor. Generating is safe by design: when an unused list already
|
|
exists the button becomes an explicit "regenerate", and a warning
|
|
appears only if the day already has recorded outcomes (which are
|
|
always preserved).
|
|
|
|
The day editor allows full correction of the record: change any call's
|
|
status, assessment, or note; delete lines entirely (effectively
|
|
excusing the student); and add calls after the fact for anyone on the
|
|
roster.
|
|
|
|
## 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
|
|
|
|
The app needs Python ≥ 3.11 with Flask, SQLAlchemy, alembic, and
|
|
numpy, plus one library that only exists on PyPI: `pylti1p3next`, the
|
|
maintained fork of PyLTI1p3. There are two equivalent ways to install,
|
|
depending on whether you prefer distribution packages or pip.
|
|
|
|
**Pure pip/venv** (any distribution; everything from PyPI):
|
|
|
|
```
|
|
python3 -m venv .venv
|
|
.venv/bin/pip install -e '.[test]'
|
|
```
|
|
|
|
**Debian/Ubuntu system packages** (only `pylti1p3next` comes from
|
|
PyPI; the venv shares the system's packages):
|
|
|
|
```
|
|
sudo apt install python3-venv python3-flask python3-sqlalchemy \
|
|
python3-alembic python3-numpy python3-pytest
|
|
python3 -m venv --system-site-packages .venv
|
|
.venv/bin/pip install pylti1p3next
|
|
.venv/bin/pip install -e .
|
|
```
|
|
|
|
Either way, then 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/`),
|
|
`COLDCALL_SECRET_KEY` for Flask sessions, `COLDCALL_LTI_CONFIG` (path
|
|
to the LTI platform configuration), and `COLDCALL_DEV_MODE=1` to enable
|
|
the fake-launch pages.
|
|
|
|
## Connecting to an LMS
|
|
|
|
Any LTI 1.3 platform can launch the tool; registration means telling
|
|
the LMS about three endpoints here: `/lti/login` (OIDC initiation),
|
|
`/lti/launch` (the launch target), and `/lti/jwks` (this tool's public
|
|
keys). In Canvas this takes a Developer Key created by an account
|
|
admin; other platforms have their own registration screens for the
|
|
same information. The platform side is described in a JSON file — copy
|
|
`lti_config.example.json` to `instance/lti_config.json` and fill in
|
|
the client id and deployment id from the registration. Generate the
|
|
tool's keypair alongside it:
|
|
|
|
```
|
|
openssl genrsa -out instance/private.key 4096
|
|
openssl rsa -in instance/private.key -pubout -out instance/public.key
|
|
```
|
|
|
|
On each instructor launch the tool refreshes the course roster from
|
|
the LMS through the Names and Role Provisioning Service, so enrollment
|
|
changes appear without any manual step. There is also a "Sync roster
|
|
now" button on the instructor page, and a management command suitable
|
|
for an hourly cron job on the server, which keeps rosters current even
|
|
when nobody has launched the tool (worth having during the add/drop
|
|
churn at the start of a term):
|
|
|
|
```
|
|
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
|
|
```
|
|
|
|
Courses whose end date has passed are skipped automatically.
|
|
|
|
Student names come from the LMS's display names, which already reflect
|
|
preferred names. Canvas users can get more through four custom
|
|
parameters on the Developer Key (these are Canvas-specific variable
|
|
substitutions; other platforms may offer equivalents under different
|
|
names, and the tool works fine without them):
|
|
|
|
```
|
|
pronouns=$com.instructure.Person.pronouns
|
|
course_start=$Canvas.course.startAt
|
|
course_end=$Canvas.course.endAt
|
|
grading_scheme=$com.instructure.Course.gradingScheme
|
|
```
|
|
|
|
Pronouns then arrive in launches and in the roster data (the tool
|
|
requests memberships scoped to the resource link, which is what makes
|
|
Canvas attach per-member custom fields) and appear on the live call
|
|
card, printed lists, and each student's own page. The course dates
|
|
bound the schedule and date pickers, and the grading scheme becomes
|
|
importable into the grade display scale with one click in settings.
|
|
All four degrade gracefully: a course, account, or platform without
|
|
them simply does without.
|
|
|
|
## Opt-outs
|
|
|
|
Students remove themselves from a day's cold-call list by picking the
|
|
date on their page; nothing else is asked. There is deliberately no
|
|
enforced submission deadline: an instructor running live calls in
|
|
class gets up-to-the-second opt-outs automatically, while one who
|
|
prints a call list beforehand should just tell students how much lead
|
|
time they need (for example, "an hour or two before class"), since
|
|
opt-outs after printing won't be on the paper.
|
|
|
|
Withdrawing an opt-out is bounded, because un-opting-out after the
|
|
fact would rewrite a student's availability record (and with it their
|
|
participation grade): withdrawal closes when class begins, using the
|
|
start time recorded on the schedule page, or at the start of the class
|
|
day when no time is recorded. Withdrawals are also recorded rather
|
|
than deleted — the opt-out export includes a withdrawn_at column, so
|
|
the full history of changes survives.
|
|
|
|
## Grading
|
|
|
|
Final participation grades use the timing-neutral foregone-
|
|
participation scheme (a port of the earlier participation_grades.R):
|
|
a student's grade is the quality of their answers minus a deduction
|
|
for participation they missed by being unavailable, estimated by
|
|
Monte Carlo simulation of the actual weighted draw and averaged over
|
|
when the absences fall, plus a small incentive penalty for being
|
|
drawn while absent with no opt-out filed. The reason for an absence
|
|
never matters, and luck of the draw never moves a grade. Parameters
|
|
(allowance, passing line, penalties, simulation size) are per-course
|
|
settings; grades are computed on demand, reviewed on the instructor's
|
|
grades page, and shown to students only when the instructor publishes
|
|
reports. With gradebook passback enabled, a review-then-push page
|
|
sends the reviewed scores to the LMS gradebook via the Assignment and Grade
|
|
Services; nothing is ever sent without explicit confirmation.
|
|
|
|
Grades are computed in points out of 100 and displayed through a
|
|
per-course scale: the built-in linear UW 4.0 map, a threshold table
|
|
(letter grades, importable in one click from the course's own LMS
|
|
grading scheme), or raw points.
|
|
|
|
The port was verified against the R engine's rendered reports from a
|
|
real course: answer quality and availability match exactly; the
|
|
simulated penalty agrees within Monte Carlo noise. `flask --app
|
|
coldcall_lti import-legacy <dir>` imports a manual-era class directory
|
|
for this kind of testing.
|
|
|
|
## Developing without an LMS
|
|
|
|
Because a Developer Key takes institutional approval to get, the app
|
|
has a fake-launch mode for local development:
|
|
|
|
```
|
|
COLDCALL_DEV_MODE=1 .venv/bin/flask --app coldcall_lti run --debug
|
|
```
|
|
|
|
Then open http://localhost:5000/dev and launch as the fake instructor
|
|
or any of the fake students. This sets up exactly the session state a
|
|
real launch would, and the fake roster flows through the same sync code
|
|
as real NRPS data, so everything past the launch behaves identically.
|
|
Dev mode also relaxes the cookie settings that LMS iframe
|
|
embedding requires in production (SameSite=None; Secure), which would
|
|
otherwise break plain-http localhost use.
|
|
|
|
The one thing dev mode cannot exercise is the real OIDC/JWT handshake
|
|
itself. For that, the app is tested against the saltire LTI platform
|
|
emulator — a hosted fake LMS that performs genuine LTI 1.3 launches
|
|
and answers NRPS requests. The full setup and re-run instructions are
|
|
in `docs/SALTIRE.md`.
|
|
|
|
## License
|
|
|
|
Copyright © 2026 Benjamin Mako Hill. This program is free software,
|
|
released under the GNU Affero General Public License, version 3 of the
|
|
License or (at your option) any later version. See the LICENSE file
|
|
for the full text.
|