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:
56
README.md
56
README.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<p><a href="{{ url_for('grades.view') }}">← back to grades</a></p>
|
<p><a href="{{ url_for('grades.view') }}">← 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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user