1
0

Speak of the LMS generically, not Canvas specifically

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>
This commit is contained in:
2026-08-03 16:03:18 -07:00
parent cbbc890d44
commit 49d037692b
13 changed files with 52 additions and 42 deletions

View File

@@ -1,19 +1,23 @@
# coldcall-lti # coldcall-lti
A Canvas external tool (LTI 1.3) for managing cold calls in case-based An LTI 1.3 external tool for managing cold calls in case-based
classes: it selects students to call using weighted randomness, records classes: it selects students to call using weighted randomness, records
what happened with each call, lets students report planned absences, and what happened with each call, lets students report planned absences, and
reports participation data back to both instructor and students. It reports participation data back to both instructor and students. It
replaces a manual workflow built on exported rosters, Google Forms, and replaces a manual workflow built on exported rosters, Google Forms, and
local scripts. 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 ## How it works
The tool is a Flask application that Canvas launches over LTI 1.3. A The tool is a Flask application that the LMS launches over LTI 1.3. A
single URL serves everyone: Canvas identifies the person and course on single URL serves everyone: the LMS identifies the person and course on
each launch, so instructors get the call-list and reporting views each launch, so instructors get the call-list and reporting views
while students get the absence form and their own history. The roster while students get the absence form and their own history. The roster
comes from Canvas through the Names and Role Provisioning Service, comes from the LMS through the Names and Role Provisioning Service,
which means adds and drops are picked up automatically rather than which means adds and drops are picked up automatically rather than
reconciled by hand. reconciled by hand.
@@ -105,15 +109,17 @@ SQLAlchemy URL; defaults to an SQLite file under `instance/`),
to the LTI platform configuration), and `COLDCALL_DEV_MODE=1` to enable to the LTI platform configuration), and `COLDCALL_DEV_MODE=1` to enable
the fake-launch pages. the fake-launch pages.
## Connecting to Canvas ## Connecting to an LMS
The tool speaks LTI 1.3, which requires a Developer Key created by a Any LTI 1.3 platform can launch the tool; registration means telling
Canvas account admin. The key points Canvas at three endpoints here: the LMS about three endpoints here: `/lti/login` (OIDC initiation),
`/lti/login` (OIDC initiation), `/lti/launch` (the launch target), and `/lti/launch` (the launch target), and `/lti/jwks` (this tool's public
`/lti/jwks` (this tool's public keys). The platform side is described keys). In Canvas this takes a Developer Key created by an account
in a JSON file — copy `lti_config.example.json` to admin; other platforms have their own registration screens for the
`instance/lti_config.json` and fill in the client id and deployment id same information. The platform side is described in a JSON file — copy
from the Developer Key. Generate the tool's keypair alongside it: `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 genrsa -out instance/private.key 4096
@@ -121,7 +127,7 @@ openssl rsa -in instance/private.key -pubout -out instance/public.key
``` ```
On each instructor launch the tool refreshes the course roster from On each instructor launch the tool refreshes the course roster from
Canvas through the Names and Role Provisioning Service, so enrollment the LMS through the Names and Role Provisioning Service, so enrollment
changes appear without any manual step. There is also a "Sync roster changes appear without any manual step. There is also a "Sync roster
now" button on the instructor page, and a management command suitable now" button on the instructor page, and a management command suitable
for an hourly cron job on the server, which keeps rosters current even for an hourly cron job on the server, which keeps rosters current even
@@ -132,11 +138,13 @@ churn at the start of a term):
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters 17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
``` ```
Courses whose Canvas end date has passed are skipped automatically. Courses whose end date has passed are skipped automatically.
Student names come from Canvas display names, which already reflect Student names come from the LMS's display names, which already reflect
preferred names. Four custom parameters on the Developer Key give the preferred names. Canvas users can get more through four custom
tool everything else it can use from Canvas: 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 pronouns=$com.instructure.Person.pronouns
@@ -151,8 +159,8 @@ Canvas attach per-member custom fields) and appear on the live call
card, printed lists, and each student's own page. The course dates card, printed lists, and each student's own page. The course dates
bound the schedule and date pickers, and the grading scheme becomes bound the schedule and date pickers, and the grading scheme becomes
importable into the grade display scale with one click in settings. importable into the grade display scale with one click in settings.
All four degrade gracefully: a course or account without them simply All four degrade gracefully: a course, account, or platform without
does without. them simply does without.
## Opt-outs ## Opt-outs
@@ -186,12 +194,12 @@ never matters, and luck of the draw never moves a grade. Parameters
settings; grades are computed on demand, reviewed on the instructor's settings; grades are computed on demand, reviewed on the instructor's
grades page, and shown to students only when the instructor publishes grades page, and shown to students only when the instructor publishes
reports. With gradebook passback enabled, a review-then-push page reports. With gradebook passback enabled, a review-then-push page
sends the reviewed scores to Canvas via the Assignment and Grade sends the reviewed scores to the LMS gradebook via the Assignment and Grade
Services; nothing is ever sent without explicit confirmation. Services; nothing is ever sent without explicit confirmation.
Grades are computed in points out of 100 and displayed through a 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 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 Canvas (letter grades, importable in one click from the course's own LMS
grading scheme), or raw points. grading scheme), or raw points.
The port was verified against the R engine's rendered reports from a The port was verified against the R engine's rendered reports from a
@@ -200,7 +208,7 @@ simulated penalty agrees within Monte Carlo noise. `flask --app
coldcall_lti import-legacy <dir>` imports a manual-era class directory coldcall_lti import-legacy <dir>` imports a manual-era class directory
for this kind of testing. for this kind of testing.
## Developing without Canvas ## Developing without an LMS
Because a Developer Key takes institutional approval to get, the app Because a Developer Key takes institutional approval to get, the app
has a fake-launch mode for local development: has a fake-launch mode for local development:
@@ -213,7 +221,7 @@ 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 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 real launch would, and the fake roster flows through the same sync code
as real NRPS data, so everything past the launch behaves identically. as real NRPS data, so everything past the launch behaves identically.
Dev mode also relaxes the cookie settings that Canvas's iframe Dev mode also relaxes the cookie settings that LMS iframe
embedding requires in production (SameSite=None; Secure), which would embedding requires in production (SameSite=None; Secure), which would
otherwise break plain-http localhost use. otherwise break plain-http localhost use.

View File

@@ -17,7 +17,7 @@ def create_app(config=Config):
app.extensions["db_session_factory"] = make_session_factory(engine) app.extensions["db_session_factory"] = make_session_factory(engine)
app.teardown_appcontext(close_db) app.teardown_appcontext(close_db)
# Canvas launches the tool in an iframe, so the session cookie must # The LMS launches the tool in an iframe, so the session cookie must
# be usable in a third-party context. Dev mode runs over plain # be usable in a third-party context. Dev mode runs over plain
# http://localhost, where Secure cookies would be dropped. # http://localhost, where Secure cookies would be dropped.
if not app.config["DEV_MODE"]: if not app.config["DEV_MODE"]:

View File

@@ -1,4 +1,4 @@
"""Fake-launch mode for development without Canvas. """Fake-launch mode for development without an LMS.
When COLDCALL_DEV_MODE=1, /dev offers a page of personas (one When COLDCALL_DEV_MODE=1, /dev offers a page of personas (one
instructor, a small roster of students) in a fake course. Launching as instructor, a small roster of students) in a fake course. Launching as

View File

@@ -666,7 +666,7 @@ def settings_import_scale():
db = get_db() db = get_db()
course = current_course() course = current_course()
if not course.canvas_grading_scheme: if not course.canvas_grading_scheme:
abort(400, "Canvas has not provided a grading scheme for this course.") abort(400, "The LMS has not provided a grading scheme for this course.")
course.scale_config = course.canvas_grading_scheme course.scale_config = course.canvas_grading_scheme
course.scale_type = "table" course.scale_type = "table"
db.commit() db.commit()

View File

@@ -113,7 +113,7 @@ class Student(Base):
__tablename__ = "students" __tablename__ = "students"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
# The LTI "sub" claim: stable, opaque, unique per Canvas user. # The LTI "sub" claim: stable, opaque, unique per LMS user.
canvas_user_id: Mapped[str] = mapped_column(String(255), unique=True) canvas_user_id: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str | None] = mapped_column(String(255)) name: Mapped[str | None] = mapped_column(String(255))
sortable_name: Mapped[str | None] = mapped_column(String(255)) sortable_name: Mapped[str | None] = mapped_column(String(255))

View File

@@ -4,6 +4,7 @@
{% if course %} {% if course %}
<p>{{ user.name }} in {{ course.title or course.lti_context_id }}.</p> <p>{{ user.name }} in {{ course.title or course.lti_context_id }}.</p>
{% else %} {% else %}
<p>This tool is meant to be launched from a Canvas course.</p> <p>This tool is meant to be launched from a course in your learning
management system.</p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -10,7 +10,7 @@
opt-out withdrawals can't be locked to class start times. opt-out withdrawals can't be locked to class start times.
<a href="{{ url_for('instructor.schedule') }}">Set up the <a href="{{ url_for('instructor.schedule') }}">Set up the
schedule</a> — pick your weekdays and the range is prefilled from schedule</a> — pick your weekdays and the range is prefilled from
the Canvas course dates when available.</p> the LMS course dates when available.</p>
<p class="muted">Also worth one look before your first class: <p class="muted">Also worth one look before your first class:
<a href="{{ url_for('instructor.settings') }}">course settings</a> <a href="{{ url_for('instructor.settings') }}">course settings</a>
(selection mode, weight, the assessment scale). Grading parameters (selection mode, weight, the assessment scale). Grading parameters
@@ -51,9 +51,9 @@
</p> </p>
<form method="post" action="{{ url_for('instructor.roster_sync') }}"> <form method="post" action="{{ url_for('instructor.roster_sync') }}">
<button type="submit">Sync roster from Canvas now</button> <button type="submit">Sync roster from the LMS now</button>
<span class="muted">The roster also refreshes automatically every <span class="muted">The roster also refreshes automatically every
time you open this tool from Canvas.</span> time you open this tool from the LMS.</span>
</form> </form>
<h2>Printable list</h2> <h2>Printable list</h2>

View File

@@ -4,7 +4,7 @@
<p><a href="{{ url_for('grades.view') }}">&larr; back to grades</a></p> <p><a href="{{ url_for('grades.view') }}">&larr; back to grades</a></p>
<h1>Review before pushing to the gradebook</h1> <h1>Review before pushing to the gradebook</h1>
<p>This will write the scores below to a "Case discussion <p>This will write the scores below to a "Case discussion
participation" column in the Canvas gradebook (computed participation" column in the LMS gradebook (computed
{{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC). Nothing is {{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC). Nothing is
sent until you confirm at the bottom.</p> sent until you confirm at the bottom.</p>
<table> <table>
@@ -18,6 +18,6 @@
{% endfor %} {% endfor %}
</table> </table>
<form method="post" action="{{ url_for('grades.push') }}"> <form method="post" action="{{ url_for('grades.push') }}">
<button type="submit" class="big">Push {{ rows | length }} grades to Canvas</button> <button type="submit" class="big">Push {{ rows | length }} grades to the gradebook</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -134,8 +134,8 @@
{% if course.canvas_grading_scheme %} {% if course.canvas_grading_scheme %}
<form method="post" action="{{ url_for('instructor.settings_import_scale') }}"> <form method="post" action="{{ url_for('instructor.settings_import_scale') }}">
<p> <p>
<button type="submit">Use the Canvas grading scheme</button> <button type="submit">Use the LMS grading scheme</button>
<span class="muted">Copies this course's grading scheme from Canvas <span class="muted">Copies this course's grading scheme from the LMS
({{ course.canvas_grading_scheme | fromjson | length }} levels) ({{ course.canvas_grading_scheme | fromjson | length }} levels)
into the threshold table.</span> into the threshold table.</span>
</p> </p>

View File

@@ -3,9 +3,10 @@
{% block body %} {% block body %}
<h1>{{ course.title or course.lti_context_id }}</h1> <h1>{{ course.title or course.lti_context_id }}</h1>
<p>Hi {{ user.name }}{% if user.pronouns %} ({{ user.pronouns }}){% endif %}.</p> <p>Hi {{ user.name }}{% if user.pronouns %} ({{ user.pronouns }}){% endif %}.</p>
<p class="muted">Your name and pronouns here are drawn from Canvas. If <p class="muted">Your name and pronouns here are drawn from your
they are not right, update them in your Canvas account settings and learning management system (e.g., Canvas). If they are not right,
they will be picked up here automatically.</p> update them in your account settings there and they will be picked
up here automatically.</p>
{% if report_available %} {% if report_available %}
<p><a href="{{ url_for('views.my_grade_report') }}"><strong>Your <p><a href="{{ url_for('views.my_grade_report') }}"><strong>Your

View File

@@ -36,7 +36,7 @@ def require_launch(view):
@functools.wraps(view) @functools.wraps(view)
def wrapped(**kwargs): def wrapped(**kwargs):
if "course_id" not in session: if "course_id" not in session:
abort(403, "Launch this tool from your Canvas course.") abort(403, "Launch this tool from your course in the LMS.")
return view(**kwargs) return view(**kwargs)
return wrapped return wrapped

View File

@@ -95,7 +95,7 @@ def test_push_flow_dev_mode(instructor):
) )
page = instructor.get("/instructor/grades/push").get_data(as_text=True) page = instructor.get("/instructor/grades/push").get_data(as_text=True)
assert "Review before pushing" in page assert "Review before pushing" in page
assert "Push 8 grades to Canvas" in page assert "Push 8 grades to the gradebook" in page
resp = instructor.post("/instructor/grades/push") resp = instructor.post("/instructor/grades/push")
page = resp.get_data(as_text=True) page = resp.get_data(as_text=True)

View File

@@ -78,4 +78,4 @@ def test_pronouns_flow_to_pages(instructor):
instructor.post("/dev/launch/dev-student-1") instructor.post("/dev/launch/dev-student-1")
page = instructor.get("/me").get_data(as_text=True) page = instructor.get("/me").get_data(as_text=True)
assert "Ada Lovelace (she/her)" in page assert "Ada Lovelace (she/her)" in page
assert "drawn from Canvas" in page assert "drawn from your" in page