1
0
Files
coldcall_lti/coldcall_lti/launch.py
Benjamin Mako Hill 7ec1be5dc6 Phase 5: reporting, exports, pronouns, and roster freshness
Instructor participation report: per-student histograms, outcome mix
by class day, and a sortable table including the fairness ratio
(answered calls over questions present for, with opt-out days out of
the denominator), plus CSV exports of students, calls, and opt-outs.

Assessment scales are now per-course data: ordered levels with labels
and points out of 100 (defaults carry the old R grading values), with
calls referencing levels by id so renames follow through to history.
Renaming, re-pointing, reordering, and adding levels are always
allowed; deleting a level in use by recorded calls is blocked.

Pronouns and course term dates come from Canvas custom variable
substitutions, at launch and roster-wide via rlid-scoped NRPS; the
student page notes that names/pronouns are Canvas-sourced. Rosters
can also be refreshed outside launches: a "Sync roster now" button
and a sync-rosters CLI command for an hourly cron job, skipping ended
courses. Alembic now runs SQLite-compatible batch migrations with a
constraint naming convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:05:34 -07:00

136 lines
4.3 KiB
Python

"""Turn validated LTI launch claims into database rows.
Kept separate from the Flask/pylti1p3 plumbing so it can be tested with
plain dicts of claims and reused by the dev-mode fake launcher.
"""
import datetime
from dataclasses import dataclass
from sqlalchemy import select
from .models import Course, Enrollment, Student, ensure_default_levels
CLAIM_CONTEXT = "https://purl.imsglobal.org/spec/lti/claim/context"
CLAIM_ROLES = "https://purl.imsglobal.org/spec/lti/claim/roles"
CLAIM_DEPLOYMENT = "https://purl.imsglobal.org/spec/lti/claim/deployment_id"
CLAIM_CUSTOM = "https://purl.imsglobal.org/spec/lti/claim/custom"
CLAIM_RESOURCE_LINK = "https://purl.imsglobal.org/spec/lti/claim/resource_link"
def clean_custom_value(value):
"""Custom parameters echo back their '$...' variable name when the
platform doesn't support the expansion; treat that as absent."""
if not value or str(value).startswith("$"):
return None
return value
def parse_custom_date(value):
"""Canvas date expansions arrive as ISO-ish timestamps; keep the
date part and tolerate absence or junk."""
value = clean_custom_value(value)
if not value:
return None
try:
return datetime.date.fromisoformat(str(value)[:10])
except ValueError:
return None
ROLE_INSTRUCTOR = "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"
ROLE_LEARNER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
@dataclass
class LaunchInfo:
context_id: str
deployment_id: str | None
course_title: str | None
user_id: str
name: str | None
email: str | None
picture: str | None
pronouns: str | None
resource_link_id: str | None
course_start: datetime.date | None
course_end: datetime.date | None
is_instructor: bool
def roles_include_instructor(roles):
return any("membership#Instructor" in r for r in roles)
def extract_launch_info(launch_data):
context = launch_data.get(CLAIM_CONTEXT, {})
custom = launch_data.get(CLAIM_CUSTOM, {})
return LaunchInfo(
context_id=context["id"],
deployment_id=launch_data.get(CLAIM_DEPLOYMENT),
course_title=context.get("title"),
user_id=launch_data["sub"],
name=launch_data.get("name"),
email=launch_data.get("email"),
picture=launch_data.get("picture"),
pronouns=clean_custom_value(custom.get("pronouns")),
resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"),
course_start=parse_custom_date(custom.get("course_start")),
course_end=parse_custom_date(custom.get("course_end")),
is_instructor=roles_include_instructor(launch_data.get(CLAIM_ROLES, [])),
)
def upsert_launch(db, info):
"""Create or refresh the course, user, and enrollment for a launch."""
course = db.execute(
select(Course).where(Course.lti_context_id == info.context_id)
).scalar_one_or_none()
if course is None:
course = Course(lti_context_id=info.context_id)
db.add(course)
db.flush()
ensure_default_levels(db, course)
if info.course_title:
course.title = info.course_title
if info.deployment_id:
course.lti_deployment_id = info.deployment_id
if info.course_start:
course.start_date = info.course_start
if info.course_end:
course.end_date = info.course_end
student = db.execute(
select(Student).where(Student.canvas_user_id == info.user_id)
).scalar_one_or_none()
if student is None:
student = Student(canvas_user_id=info.user_id)
db.add(student)
if info.name:
student.name = info.name
if info.email:
student.email = info.email
if info.picture:
student.avatar_url = info.picture
if info.pronouns:
student.pronouns = info.pronouns
db.flush()
enrollment = db.execute(
select(Enrollment).where(
Enrollment.course_id == course.id,
Enrollment.student_id == student.id,
)
).scalar_one_or_none()
role = "instructor" if info.is_instructor else "student"
if enrollment is None:
enrollment = Enrollment(
course_id=course.id, student_id=student.id, role=role
)
db.add(enrollment)
else:
enrollment.role = role
enrollment.active = True
db.flush()
return course, student