1
0

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>
This commit is contained in:
2026-07-31 17:05:34 -07:00
parent 7845167ec5
commit 7ec1be5dc6
32 changed files with 1274 additions and 44 deletions

View File

@@ -83,7 +83,37 @@ 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 Canvas through the Names and Role Provisioning Service, so enrollment
changes appear without any manual step. 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 Canvas end date has passed are skipped automatically.
Student names come from Canvas display names, which already reflect
preferred names. Pronouns require one extra piece of Developer Key
configuration: add a custom parameter
```
pronouns=$com.instructure.Person.pronouns
course_start=$Canvas.course.startAt
course_end=$Canvas.course.endAt
```
The course dates bound the schedule and date pickers; the tool works
fine without them when a course has no dates set in Canvas.
to the key. Canvas then includes each person's pronouns 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). Pronouns appear on the live call card, printed call lists,
and each student's own page. If the account has pronouns disabled,
everything simply shows without them.
## Developing without Canvas ## Developing without Canvas

View File

@@ -34,6 +34,10 @@ def create_app(config=Config):
app.register_blueprint(dev.bp) app.register_blueprint(dev.bp)
from .cli import register_cli
register_cli(app)
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
return {"status": "ok"} return {"status": "ok"}

50
coldcall_lti/cli.py Normal file
View File

@@ -0,0 +1,50 @@
"""Management commands, run as `flask --app coldcall_lti <command>`.
sync-rosters exists so a crontab entry can keep rosters fresh between
launches (enrollment shifts constantly in the first weeks of a term):
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
"""
import datetime
import click
from flask import current_app
from sqlalchemy import select
from .models import Course
from .roster import sync_roster
def register_cli(app):
@app.cli.command("sync-rosters")
def sync_rosters():
"""Refresh every syncable course roster from its LMS."""
from .lti import nrps_members_for_course
db = current_app.extensions["db_session_factory"]()
today = datetime.date.today()
try:
for course in db.execute(select(Course)).scalars():
label = course.title or course.lti_context_id
if course.end_date and course.end_date < today:
click.echo(f"{label}: ended, skipped")
continue
if not course.nrps_url:
click.echo(f"{label}: no roster service recorded, skipped")
continue
try:
members = nrps_members_for_course(course)
except Exception as exc:
click.echo(f"{label}: sync failed: {exc}", err=True)
continue
if members is None:
click.echo(f"{label}: no registration found, skipped")
continue
active, deactivated = sync_roster(db, course, members)
db.commit()
click.echo(
f"{label}: {active} active, {deactivated} deactivated"
)
finally:
db.close()

View File

@@ -1,10 +1,20 @@
from flask import current_app, g from flask import current_app, g
from sqlalchemy import create_engine from sqlalchemy import MetaData, create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker from sqlalchemy.orm import DeclarativeBase, sessionmaker
# Deterministic constraint names, required for SQLite batch migrations
# and portable to MariaDB/MySQL.
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass metadata = MetaData(naming_convention=NAMING_CONVENTION)
def make_engine(url, echo=False): def make_engine(url, echo=False):

View File

@@ -14,7 +14,7 @@ from sqlalchemy import select
from .db import get_db from .db import get_db
from .launch import ROLE_INSTRUCTOR, ROLE_LEARNER from .launch import ROLE_INSTRUCTOR, ROLE_LEARNER
from .models import ClassDay, Course, Student, class_days from .models import ClassDay, Course, Student, class_days, ensure_default_levels
from .roster import sync_roster from .roster import sync_roster
bp = Blueprint("dev", __name__, url_prefix="/dev") bp = Blueprint("dev", __name__, url_prefix="/dev")
@@ -28,6 +28,7 @@ DEV_MEMBERS = [
"given_name": "Ida", "given_name": "Ida",
"family_name": "Instructor", "family_name": "Instructor",
"email": "instructor@example.edu", "email": "instructor@example.edu",
"pronouns": "she/her",
"roles": [ROLE_INSTRUCTOR], "roles": [ROLE_INSTRUCTOR],
"status": "Active", "status": "Active",
} }
@@ -38,19 +39,20 @@ DEV_MEMBERS = [
"given_name": given, "given_name": given,
"family_name": family, "family_name": family,
"email": f"{given.lower()}@example.edu", "email": f"{given.lower()}@example.edu",
"pronouns": pronouns,
"roles": [ROLE_LEARNER], "roles": [ROLE_LEARNER],
"status": "Active", "status": "Active",
} }
for i, (given, family) in enumerate( for i, (given, family, pronouns) in enumerate(
[ [
("Ada", "Lovelace"), ("Ada", "Lovelace", "she/her"),
("Grace", "Hopper"), ("Grace", "Hopper", "she/her"),
("Alan", "Turing"), ("Alan", "Turing", "he/him"),
("Annie", "Easley"), ("Annie", "Easley", "she/her"),
("Edsger", "Dijkstra"), ("Edsger", "Dijkstra", None),
("Katherine", "Johnson"), ("Katherine", "Johnson", "she/her"),
("Donald", "Knuth"), ("Donald", "Knuth", "he/him"),
("Radia", "Perlman"), ("Radia", "Perlman", None),
], ],
start=1, start=1,
) )
@@ -62,9 +64,16 @@ def _ensure_dev_course(db):
select(Course).where(Course.lti_context_id == DEV_CONTEXT_ID) select(Course).where(Course.lti_context_id == DEV_CONTEXT_ID)
).scalar_one_or_none() ).scalar_one_or_none()
if course is None: if course is None:
course = Course(lti_context_id=DEV_CONTEXT_ID, title="Development Course") today = datetime.date.today()
course = Course(
lti_context_id=DEV_CONTEXT_ID,
title="Development Course",
start_date=today - datetime.timedelta(days=14),
end_date=today + datetime.timedelta(days=56),
)
db.add(course) db.add(course)
db.flush() db.flush()
ensure_default_levels(db, course)
sync_roster(db, course, DEV_MEMBERS) sync_roster(db, course, DEV_MEMBERS)
# A Tue/Thu meeting pattern for the next few weeks, so the opt-out # A Tue/Thu meeting pattern for the next few weeks, so the opt-out

View File

@@ -1,8 +1,12 @@
import csv
import datetime import datetime
import io
from flask import ( from flask import (
Blueprint, Blueprint,
Response,
abort, abort,
current_app,
redirect, redirect,
render_template, render_template,
request, request,
@@ -10,24 +14,26 @@ from flask import (
) )
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from . import calls from . import calls, reports
from .db import get_db from .db import get_db
from .models import ( from .models import (
ASSESSMENTS,
CALL_STATUSES, CALL_STATUSES,
SELECTION_CYCLE, SELECTION_CYCLE,
SELECTION_WEIGHTED, SELECTION_WEIGHTED,
STATUS_ANSWERED, STATUS_ANSWERED,
STATUS_MISSING, STATUS_MISSING,
STATUS_SKIPPED, STATUS_SKIPPED,
AssessmentLevel,
Call, Call,
ClassDay, ClassDay,
Enrollment, Enrollment,
OptOut, OptOut,
Student, Student,
assessment_levels,
class_days, class_days,
students_present, students_present,
) )
from .roster import sync_roster
from .views import current_course, require_instructor from .views import current_course, require_instructor
bp = Blueprint("instructor", __name__, url_prefix="/instructor") bp = Blueprint("instructor", __name__, url_prefix="/instructor")
@@ -106,7 +112,11 @@ def live():
if c.status != calls.STATUS_PENDING if c.status != calls.STATUS_PENDING
] ]
context = _day_context(db, course, day) context = _day_context(db, course, day)
context.update(call=call, day_calls=day_calls, assessments=ASSESSMENTS) context.update(
call=call,
day_calls=day_calls,
levels=assessment_levels(db, course.id),
)
return render_template("live.html", **context) return render_template("live.html", **context)
@@ -130,15 +140,18 @@ def call_outcome(call_id):
call = _get_course_call(db, call_id) call = _get_course_call(db, call_id)
action = request.form.get("action", "") action = request.form.get("action", "")
if action in ASSESSMENTS: if action.startswith("level-"):
level = db.get(AssessmentLevel, int(action.removeprefix("level-")))
if level is None or level.course_id != call.course_id:
abort(400, "Unknown assessment level.")
call.status = STATUS_ANSWERED call.status = STATUS_ANSWERED
call.assessment = action call.assessment_id = level.id
elif action == "skip": elif action == "skip":
call.status = STATUS_SKIPPED call.status = STATUS_SKIPPED
call.assessment = None call.assessment_id = None
elif action == "missing": elif action == "missing":
call.status = STATUS_MISSING call.status = STATUS_MISSING
call.assessment = None call.assessment_id = None
else: else:
abort(400, "Unknown outcome.") abort(400, "Unknown outcome.")
db.commit() db.commit()
@@ -182,7 +195,7 @@ def day_edit(date_str):
context = _day_context(db, course, day) context = _day_context(db, course, day)
context.update( context.update(
day_calls=calls.calls_for_day(db, course.id, day), day_calls=calls.calls_for_day(db, course.id, day),
assessments=ASSESSMENTS, levels=assessment_levels(db, course.id),
statuses=CALL_STATUSES, statuses=CALL_STATUSES,
) )
return render_template("day_edit.html", **context) return render_template("day_edit.html", **context)
@@ -195,6 +208,7 @@ def day_save(date_str):
course = current_course() course = current_course()
day = _parse_date(date_str) day = _parse_date(date_str)
level_ids = {lvl.id for lvl in assessment_levels(db, course.id)}
for call in calls.calls_for_day(db, course.id, day): for call in calls.calls_for_day(db, course.id, day):
if request.form.get(f"delete-{call.id}"): if request.form.get(f"delete-{call.id}"):
db.delete(call) db.delete(call)
@@ -202,14 +216,141 @@ def day_save(date_str):
status = request.form.get(f"status-{call.id}") status = request.form.get(f"status-{call.id}")
if status in CALL_STATUSES: if status in CALL_STATUSES:
call.status = status call.status = status
assessment = request.form.get(f"assessment-{call.id}", "") assessment = request.form.get(f"assessment-{call.id}", type=int)
call.assessment = assessment if assessment in ASSESSMENTS else None call.assessment_id = assessment if assessment in level_ids else None
note = request.form.get(f"note-{call.id}", "").strip() note = request.form.get(f"note-{call.id}", "").strip()
call.note = note or None call.note = note or None
db.commit() db.commit()
return redirect(url_for(".day_edit", date_str=day.isoformat())) return redirect(url_for(".day_edit", date_str=day.isoformat()))
REPORT_SORTS = {
"name": lambda r: (r["student"].sortable_name or r["student"].name or ""),
"answered": lambda r: -r["answered"],
"missing": lambda r: -r["missing"],
"optouts": lambda r: -r["optouts"],
"prop": lambda r: -(r["prop_asked"] if r["prop_asked"] is not None else -1),
}
@bp.get("/report")
@require_instructor
def report():
db = get_db()
course = current_course()
rows = reports.student_stats(db, course)
sort = request.args.get("sort", "name")
rows.sort(key=REPORT_SORTS.get(sort, REPORT_SORTS["name"]))
by_day = reports.assessments_by_day(db, course.id)
outcome_totals = {}
for day in by_day:
for k, v in day["counts"].items():
outcome_totals[k] = outcome_totals.get(k, 0) + v
level_labels = [lvl.label for lvl in assessment_levels(db, course.id)]
return render_template(
"report.html",
course=course,
rows=rows,
sort=sort,
calls_hist=reports.histogram([r["answered"] for r in rows]),
optouts_hist=reports.histogram([r["optouts"] for r in rows]),
by_day=by_day,
outcome_totals=outcome_totals,
total_questions=sum(d["total"] for d in by_day),
outcome_order=level_labels + ["(unassessed)", "missing"],
)
def _csv_response(filename, header, rows):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerows(rows)
return Response(
buf.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@bp.get("/export/students.csv")
@require_instructor
def export_students():
db = get_db()
course = current_course()
return _csv_response(
"students.csv",
[
"name", "sortable_name", "email", "canvas_user_id",
"answered", "missing", "optouts",
"questions_present", "prop_asked",
],
[
[
r["student"].name, r["student"].sortable_name,
r["student"].email, r["student"].canvas_user_id,
r["answered"], r["missing"], r["optouts"],
r["questions_present"],
round(r["prop_asked"], 4) if r["prop_asked"] is not None else "",
]
for r in reports.student_stats(db, course)
],
)
@bp.get("/export/calls.csv")
@require_instructor
def export_calls():
db = get_db()
course = current_course()
rows = db.execute(
select(Call, Student)
.join(Student, Call.student_id == Student.id)
.where(Call.course_id == course.id)
.order_by(Call.session_date, Call.id)
).all()
return _csv_response(
"calls.csv",
["date", "name", "email", "canvas_user_id",
"status", "assessment", "note", "created_at"],
[
[
call.session_date.isoformat(), student.name, student.email,
student.canvas_user_id, call.status, call.assessment or "",
call.note or "", call.created_at.isoformat(),
]
for call, student in rows
],
)
@bp.get("/export/optouts.csv")
@require_instructor
def export_optouts():
db = get_db()
course = current_course()
rows = db.execute(
select(OptOut, Student)
.join(Student, OptOut.student_id == Student.id)
.where(OptOut.course_id == course.id)
.order_by(OptOut.date, Student.sortable_name)
).all()
return _csv_response(
"optouts.csv",
["date", "name", "email", "canvas_user_id", "submitted_at"],
[
[
optout.date.isoformat(), student.name, student.email,
student.canvas_user_id, optout.created_at.isoformat(),
]
for optout, student in rows
],
)
@bp.get("/schedule") @bp.get("/schedule")
@require_instructor @require_instructor
def schedule(): def schedule():
@@ -273,16 +414,53 @@ def schedule_delete(day_id):
return redirect(url_for(".schedule")) return redirect(url_for(".schedule"))
@bp.post("/roster/sync")
@require_instructor
def roster_sync():
from .lti import nrps_members_for_course
db = get_db()
course = current_course()
members = nrps_members_for_course(course)
if members is None and current_app.config["DEV_MODE"]:
from .dev import DEV_MEMBERS
members = DEV_MEMBERS
if members is None:
abort(400, "No roster service recorded for this course yet.")
sync_roster(db, course, members)
db.commit()
return redirect(url_for(".home"))
@bp.get("/settings") @bp.get("/settings")
@require_instructor @require_instructor
def settings(): def settings():
db = get_db()
course = current_course()
return render_template( return render_template(
"settings.html", "settings.html",
course=current_course(), course=course,
levels=assessment_levels(db, course.id),
level_use=_level_use_counts(db, course.id),
modes=(SELECTION_WEIGHTED, SELECTION_CYCLE), modes=(SELECTION_WEIGHTED, SELECTION_CYCLE),
) )
def _level_use_counts(db, course_id):
return dict(
db.execute(
select(Call.assessment_id, func.count())
.where(
Call.course_id == course_id,
Call.assessment_id.is_not(None),
)
.group_by(Call.assessment_id)
).all()
)
@bp.post("/settings") @bp.post("/settings")
@require_instructor @require_instructor
def settings_save(): def settings_save():
@@ -296,5 +474,37 @@ def settings_save():
if weight and weight >= 1.0: if weight and weight >= 1.0:
course.weight_factor = weight course.weight_factor = weight
course.show_assessments = bool(request.form.get("show_assessments")) course.show_assessments = bool(request.form.get("show_assessments"))
# Assessment scale. Levels in use by recorded calls cannot be
# deleted (rename or re-point them instead).
used = set(_level_use_counts(db, course.id))
for level in assessment_levels(db, course.id):
if f"label-{level.id}" not in request.form:
continue
if request.form.get(f"delete-{level.id}") and level.id not in used:
db.delete(level)
continue
label = request.form.get(f"label-{level.id}", "").strip()
points = request.form.get(f"points-{level.id}", type=float)
position = request.form.get(f"position-{level.id}", type=int)
if label:
level.label = label
if points is not None and 0 <= points <= 100:
level.points = points
if position is not None:
level.position = position
new_label = request.form.get("new_label", "").strip()
new_points = request.form.get("new_points", type=float)
if new_label and new_points is not None and 0 <= new_points <= 100:
existing = assessment_levels(db, course.id)
db.add(
AssessmentLevel(
course_id=course.id,
label=new_label,
points=new_points,
position=max((l.position for l in existing), default=-1) + 1,
)
)
db.commit() db.commit()
return redirect(url_for(".settings")) return redirect(url_for(".settings"))

View File

@@ -4,15 +4,38 @@ 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. plain dicts of claims and reused by the dev-mode fake launcher.
""" """
import datetime
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy import select from sqlalchemy import select
from .models import Course, Enrollment, Student from .models import Course, Enrollment, Student, ensure_default_levels
CLAIM_CONTEXT = "https://purl.imsglobal.org/spec/lti/claim/context" CLAIM_CONTEXT = "https://purl.imsglobal.org/spec/lti/claim/context"
CLAIM_ROLES = "https://purl.imsglobal.org/spec/lti/claim/roles" CLAIM_ROLES = "https://purl.imsglobal.org/spec/lti/claim/roles"
CLAIM_DEPLOYMENT = "https://purl.imsglobal.org/spec/lti/claim/deployment_id" 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_INSTRUCTOR = "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"
ROLE_LEARNER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner" ROLE_LEARNER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
@@ -27,6 +50,10 @@ class LaunchInfo:
name: str | None name: str | None
email: str | None email: str | None
picture: 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 is_instructor: bool
@@ -36,6 +63,7 @@ def roles_include_instructor(roles):
def extract_launch_info(launch_data): def extract_launch_info(launch_data):
context = launch_data.get(CLAIM_CONTEXT, {}) context = launch_data.get(CLAIM_CONTEXT, {})
custom = launch_data.get(CLAIM_CUSTOM, {})
return LaunchInfo( return LaunchInfo(
context_id=context["id"], context_id=context["id"],
deployment_id=launch_data.get(CLAIM_DEPLOYMENT), deployment_id=launch_data.get(CLAIM_DEPLOYMENT),
@@ -44,6 +72,10 @@ def extract_launch_info(launch_data):
name=launch_data.get("name"), name=launch_data.get("name"),
email=launch_data.get("email"), email=launch_data.get("email"),
picture=launch_data.get("picture"), 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, [])), is_instructor=roles_include_instructor(launch_data.get(CLAIM_ROLES, [])),
) )
@@ -56,10 +88,16 @@ def upsert_launch(db, info):
if course is None: if course is None:
course = Course(lti_context_id=info.context_id) course = Course(lti_context_id=info.context_id)
db.add(course) db.add(course)
db.flush()
ensure_default_levels(db, course)
if info.course_title: if info.course_title:
course.title = info.course_title course.title = info.course_title
if info.deployment_id: if info.deployment_id:
course.lti_deployment_id = 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( student = db.execute(
select(Student).where(Student.canvas_user_id == info.user_id) select(Student).where(Student.canvas_user_id == info.user_id)
@@ -73,6 +111,8 @@ def upsert_launch(db, info):
student.email = info.email student.email = info.email
if info.picture: if info.picture:
student.avatar_url = info.picture student.avatar_url = info.picture
if info.pronouns:
student.pronouns = info.pronouns
db.flush() db.flush()
enrollment = db.execute( enrollment = db.execute(

View File

@@ -8,12 +8,35 @@ from pylti1p3.contrib.flask import (
) )
from pylti1p3.tool_config import ToolConfJsonFile from pylti1p3.tool_config import ToolConfJsonFile
from pylti1p3.names_roles import NamesRolesProvisioningService
from pylti1p3.service_connector import ServiceConnector
from .db import get_db from .db import get_db
from .launch import extract_launch_info, upsert_launch from .launch import extract_launch_info, upsert_launch
from .roster import sync_roster from .roster import sync_roster
bp = Blueprint("lti", __name__, url_prefix="/lti") bp = Blueprint("lti", __name__, url_prefix="/lti")
NRPS_CLAIM = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice"
def nrps_members_for_course(course):
"""Fetch the course roster from NRPS outside a launch, using the
platform details captured at the last launch. Returns None if the
course hasn't recorded them yet."""
if not (course.nrps_url and course.lti_issuer):
return None
registration = get_tool_conf().find_registration_by_params(
course.lti_issuer, client_id=course.lti_client_id
)
if registration is None:
return None
service = NamesRolesProvisioningService(
ServiceConnector(registration),
{"context_memberships_url": course.nrps_url},
)
return service.get_members()
def get_tool_conf(): def get_tool_conf():
return ToolConfJsonFile(current_app.config["LTI_CONFIG_PATH"]) return ToolConfJsonFile(current_app.config["LTI_CONFIG_PATH"])
@@ -50,10 +73,23 @@ def launch():
db = get_db() db = get_db()
course, student = upsert_launch(db, info) course, student = upsert_launch(db, info)
# Remember how to reach NRPS outside a launch (the manual roster
# refresh); aud may be a single client id or a list.
aud = launch_data.get("aud")
course.lti_issuer = launch_data.get("iss")
course.lti_client_id = aud[0] if isinstance(aud, list) else aud
nrps_data = launch_data.get(NRPS_CLAIM, {})
if nrps_data.get("context_memberships_url"):
course.nrps_url = nrps_data["context_memberships_url"]
# Instructor launches refresh the roster, so adds and drops are # Instructor launches refresh the roster, so adds and drops are
# picked up before every class without a separate sync step. # picked up before every class without a separate sync step.
if info.is_instructor and message_launch.has_nrps(): if info.is_instructor and message_launch.has_nrps():
members = message_launch.get_nrps().get_members() # Scoping to the resource link makes Canvas include per-member
# custom expansions (pronouns) in the membership response.
members = message_launch.get_nrps().get_members(
resource_link_id=info.resource_link_id
)
sync_roster(db, course, members) sync_roster(db, course, members)
db.commit() db.commit()

View File

@@ -32,8 +32,16 @@ STATUS_SKIPPED = "skipped"
STATUS_PENDING = "pending" STATUS_PENDING = "pending"
CALL_STATUSES = (STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, STATUS_PENDING) CALL_STATUSES = (STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, STATUS_PENDING)
# Default assessment vocabulary, carried over from the manual system. # Default assessment vocabulary and points, carried over from the
ASSESSMENTS = ("GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER") # manual system: compute_final_case_grades.R scored GOOD=100,
# SATISFACTORY and POOR one and two UW-4.0-scale steps down
# (100 - n*50/(4-0.7)), and NO MEANINGFUL ANSWER zero.
DEFAULT_ASSESSMENT_LEVELS = (
("GOOD", 100.0),
("SATISFACTORY", 84.85),
("POOR", 69.7),
("NO MEANINGFUL ANSWER", 0.0),
)
SELECTION_WEIGHTED = "weighted" SELECTION_WEIGHTED = "weighted"
SELECTION_CYCLE = "cycle" SELECTION_CYCLE = "cycle"
@@ -47,6 +55,18 @@ class Course(Base):
lti_deployment_id: Mapped[str | None] = mapped_column(String(255)) lti_deployment_id: Mapped[str | None] = mapped_column(String(255))
title: Mapped[str | None] = mapped_column(String(255)) title: Mapped[str | None] = mapped_column(String(255))
# Enough platform detail to call NRPS outside a launch, for the
# on-demand roster refresh. Captured/updated on each launch.
lti_issuer: Mapped[str | None] = mapped_column(String(255))
lti_client_id: Mapped[str | None] = mapped_column(String(255))
nrps_url: Mapped[str | None] = mapped_column(String(1024))
# Course term dates from Canvas ($Canvas.course.startAt/endAt
# custom parameters), used to bound date pickers. Nullable: not
# every course has them set in Canvas.
start_date: Mapped[datetime.date | None] = mapped_column(Date)
end_date: Mapped[datetime.date | None] = mapped_column(Date)
# Per-course settings. # Per-course settings.
weight_factor: Mapped[float] = mapped_column(Float, default=2.0) weight_factor: Mapped[float] = mapped_column(Float, default=2.0)
selection_mode: Mapped[str] = mapped_column( selection_mode: Mapped[str] = mapped_column(
@@ -70,6 +90,7 @@ class Student(Base):
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))
email: Mapped[str | None] = mapped_column(String(255)) email: Mapped[str | None] = mapped_column(String(255))
pronouns: Mapped[str | None] = mapped_column(String(64))
avatar_url: Mapped[str | None] = mapped_column(String(1024)) avatar_url: Mapped[str | None] = mapped_column(String(1024))
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student") enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
@@ -90,6 +111,50 @@ class Enrollment(Base):
student: Mapped[Student] = relationship(back_populates="enrollments") student: Mapped[Student] = relationship(back_populates="enrollments")
class AssessmentLevel(Base):
"""One rung of a course's assessment scale: a label and its points
out of 100. Calls reference levels by id, so renaming a level
renames it everywhere, past calls included."""
__tablename__ = "assessment_levels"
__table_args__ = (UniqueConstraint("course_id", "label"),)
id: Mapped[int] = mapped_column(primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
label: Mapped[str] = mapped_column(String(64))
points: Mapped[float] = mapped_column(Float)
position: Mapped[int] = mapped_column(default=0)
def ensure_default_levels(session, course):
existing = session.execute(
select(AssessmentLevel.id)
.where(AssessmentLevel.course_id == course.id)
.limit(1)
).first()
if existing:
return
for i, (label, points) in enumerate(DEFAULT_ASSESSMENT_LEVELS):
session.add(
AssessmentLevel(
course_id=course.id, label=label, points=points, position=i
)
)
session.flush()
def assessment_levels(session, course_id):
return (
session.execute(
select(AssessmentLevel)
.where(AssessmentLevel.course_id == course_id)
.order_by(AssessmentLevel.position, AssessmentLevel.id)
)
.scalars()
.all()
)
class ClassDay(Base): class ClassDay(Base):
"""A day the class actually meets. When a course has these, the """A day the class actually meets. When a course has these, the
student opt-out form only offers real class days; without them it student opt-out form only offers real class days; without them it
@@ -124,13 +189,20 @@ class Call(Base):
student_id: Mapped[int] = mapped_column(ForeignKey("students.id")) student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
session_date: Mapped[datetime.date] = mapped_column(Date) session_date: Mapped[datetime.date] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING) status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING)
assessment: Mapped[str | None] = mapped_column(String(32)) assessment_id: Mapped[int | None] = mapped_column(
ForeignKey("assessment_levels.id")
)
note: Mapped[str | None] = mapped_column(Text) note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime.datetime] = mapped_column( created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow DateTime, default=utcnow
) )
student: Mapped[Student] = relationship() student: Mapped[Student] = relationship()
level: Mapped["AssessmentLevel | None"] = relationship()
@property
def assessment(self):
return self.level.label if self.level else None
def answered_call_counts(session, course_id): def answered_call_counts(session, course_id):

123
coldcall_lti/reports.py Normal file
View File

@@ -0,0 +1,123 @@
"""Data assembly for the instructor reports and CSV exports.
The fairness numbers follow the old track_participation.R /
compute_final_case_grades.R logic: a student's "questions present for"
is the sum of resolved calls on each day they were not opted out, and
prop_asked is their answered calls over that denominator. Resolved
means answered or missing; skipped and pending calls count nowhere.
"""
from collections import Counter, defaultdict
from sqlalchemy import select
from .models import (
STATUS_ANSWERED,
STATUS_MISSING,
Call,
Enrollment,
OptOut,
Student,
)
RESOLVED = (STATUS_ANSWERED, STATUS_MISSING)
def _resolved_calls(db, course_id):
return (
db.execute(
select(Call)
.where(Call.course_id == course_id, Call.status.in_(RESOLVED))
.order_by(Call.session_date, Call.id)
)
.scalars()
.all()
)
def per_day_question_counts(db, course_id):
counts = Counter()
for call in _resolved_calls(db, course_id):
counts[call.session_date] += 1
return dict(counts)
def student_stats(db, course):
"""One row per active student: call counts, opt-outs, unreported
absences, and the fairness ratio."""
enrollments = db.execute(
select(Enrollment, Student)
.join(Student, Enrollment.student_id == Student.id)
.where(
Enrollment.course_id == course.id,
Enrollment.role == "student",
Enrollment.active.is_(True),
)
.order_by(Student.sortable_name, Student.name)
).all()
answered = Counter()
missing = Counter()
for call in _resolved_calls(db, course.id):
if call.status == STATUS_ANSWERED:
answered[call.student_id] += 1
else:
missing[call.student_id] += 1
optout_dates = defaultdict(set)
for optout in db.execute(
select(OptOut).where(OptOut.course_id == course.id)
).scalars():
optout_dates[optout.student_id].add(optout.date)
day_counts = per_day_question_counts(db, course.id)
rows = []
for enrollment, student in enrollments:
q_present = sum(
n
for day, n in day_counts.items()
if day not in optout_dates[student.id]
)
n_answered = answered[student.id]
rows.append(
{
"student": student,
"answered": n_answered,
"missing": missing[student.id],
"optouts": len(optout_dates[student.id]),
"questions_present": q_present,
"prop_asked": (n_answered / q_present) if q_present else None,
}
)
return rows
def assessments_by_day(db, course_id):
"""Per class day, how the resolved calls broke down: each
assessment plus 'missing'. Ordered by date."""
by_day = defaultdict(Counter)
for call in _resolved_calls(db, course_id):
if call.status == STATUS_MISSING:
by_day[call.session_date]["missing"] += 1
else:
by_day[call.session_date][call.assessment or "(unassessed)"] += 1
return [
{"day": day, "counts": dict(counts), "total": sum(counts.values())}
for day, counts in sorted(by_day.items())
]
def histogram(values):
"""Bins covering 0..max(values) as {value, students} dicts, plus
the largest bin size, for the HTML/CSS column charts."""
if not values:
return {"bins": [], "max_students": 1}
bins = Counter(values)
return {
"bins": [
{"value": k, "students": bins.get(k, 0)}
for k in range(0, max(values) + 1)
],
"max_students": max(bins.values()),
}

View File

@@ -7,9 +7,24 @@ dev-mode fake roster.
from sqlalchemy import select from sqlalchemy import select
from .launch import CLAIM_CUSTOM, clean_custom_value
from .models import Enrollment, Student from .models import Enrollment, Student
def _member_pronouns(member):
"""Canvas delivers pronouns through the per-member message array's
custom claim (requires the tool's custom parameter
pronouns=$com.instructure.Person.pronouns and an rlid-scoped
membership request). Dev mode uses a plain 'pronouns' key."""
if member.get("pronouns"):
return clean_custom_value(member["pronouns"])
for message in member.get("message", []):
value = message.get(CLAIM_CUSTOM, {}).get("pronouns")
if clean_custom_value(value):
return value
return None
def _member_role(roles): def _member_role(roles):
return "instructor" if any("Instructor" in r for r in roles) else "student" return "instructor" if any("Instructor" in r for r in roles) else "student"
@@ -49,6 +64,9 @@ def sync_roster(db, course, members):
student.email = member["email"] student.email = member["email"]
if member.get("picture"): if member.get("picture"):
student.avatar_url = member["picture"] student.avatar_url = member["picture"]
pronouns = _member_pronouns(member)
if pronouns:
student.pronouns = pronouns
db.flush() db.flush()
seen_student_ids.add(student.id) seen_student_ids.add(student.id)

View File

@@ -12,6 +12,7 @@
.muted { color: #666; } .muted { color: #666; }
.callcard { margin: 1.5rem 0; } .callcard { margin: 1.5rem 0; }
.callname { font-size: 2.25rem; font-weight: bold; margin: 0.5rem 0 0; } .callname { font-size: 2.25rem; font-weight: bold; margin: 0.5rem 0 0; }
.pronouns { font-size: 1.25rem; font-weight: normal; color: #555; }
button { padding: 0.4rem 0.8rem; margin: 0.15rem; } button { padding: 0.4rem 0.8rem; margin: 0.15rem; }
button.big { font-size: 1.5rem; padding: 1rem 2rem; } button.big { font-size: 1.5rem; padding: 1rem 2rem; }
.printlist td { border-bottom: 1px solid #ccc; padding: 0.5rem 0.75rem 0.5rem 0; } .printlist td { border-bottom: 1px solid #ccc; padding: 0.5rem 0.75rem 0.5rem 0; }
@@ -22,6 +23,15 @@
.hist .bar.me { background: #d9822b; } .hist .bar.me { background: #d9822b; }
.hist .bar-n { font-size: 0.75rem; } .hist .bar-n { font-size: 0.75rem; }
.hist .bar-x { font-size: 0.8rem; } .hist .bar-x { font-size: 0.8rem; }
.stack { display: flex; height: 1.1rem; min-width: 12rem; }
.daybars td { padding-right: 0.6rem; }
.key { display: inline-block; width: 0.9rem; height: 0.9rem; vertical-align: middle; }
.o-good { background: #4a7c59; }
.o-satisfactory { background: #7a9cc6; }
.o-poor { background: #d9a53f; }
.o-no-meaningful-answer { background: #b5535a; }
.o-unassessed { background: #cfcfcf; }
.o-missing { background: #777; }
@media print { @media print {
.no-print { display: none; } .no-print { display: none; }
body { margin: 0.5rem; max-width: none; } body { margin: 0.5rem; max-width: none; }

View File

@@ -26,8 +26,8 @@
<td> <td>
<select name="assessment-{{ c.id }}"> <select name="assessment-{{ c.id }}">
<option value=""></option> <option value=""></option>
{% for a in assessments %} {% for lvl in levels %}
<option value="{{ a }}" {{ "selected" if c.assessment == a }}>{{ a }}</option> <option value="{{ lvl.id }}" {{ "selected" if c.assessment_id == lvl.id }}>{{ lvl.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
</td> </td>

View File

@@ -10,10 +10,17 @@
<p> <p>
<a href="{{ url_for('instructor.live') }}">Live cold call</a> · <a href="{{ url_for('instructor.live') }}">Live cold call</a> ·
<a href="{{ url_for('instructor.day_edit', date_str=today.isoformat()) }}">Today's calls</a> · <a href="{{ url_for('instructor.day_edit', date_str=today.isoformat()) }}">Today's calls</a> ·
<a href="{{ url_for('instructor.report') }}">Report</a> ·
<a href="{{ url_for('instructor.schedule') }}">Schedule</a> · <a href="{{ url_for('instructor.schedule') }}">Schedule</a> ·
<a href="{{ url_for('instructor.settings') }}">Settings</a> <a href="{{ url_for('instructor.settings') }}">Settings</a>
</p> </p>
<form method="post" action="{{ url_for('instructor.roster_sync') }}">
<button type="submit">Sync roster from Canvas now</button>
<span class="muted">The roster also refreshes automatically every
time you open this tool from Canvas.</span>
</form>
<h2>Printable list</h2> <h2>Printable list</h2>
<form method="post" <form method="post"
action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}"> action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}">

View File

@@ -13,15 +13,19 @@
{% if call.student.avatar_url %} {% if call.student.avatar_url %}
<img src="{{ call.student.avatar_url }}" alt="" width="120"> <img src="{{ call.student.avatar_url }}" alt="" width="120">
{% endif %} {% endif %}
<p class="callname">{{ call.student.name }}</p> <p class="callname">{{ call.student.name }}
{% if call.student.pronouns %}
<span class="pronouns">({{ call.student.pronouns }})</span>
{% endif %}
</p>
{% if call.student.sortable_name %} {% if call.student.sortable_name %}
<p class="muted">{{ call.student.sortable_name }}</p> <p class="muted">{{ call.student.sortable_name }}</p>
{% endif %} {% endif %}
</div> </div>
<form method="post" <form method="post"
action="{{ url_for('instructor.call_outcome', call_id=call.id) }}"> action="{{ url_for('instructor.call_outcome', call_id=call.id) }}">
{% for a in assessments %} {% for lvl in levels %}
<button type="submit" name="action" value="{{ a }}">{{ a }}</button> <button type="submit" name="action" value="level-{{ lvl.id }}">{{ lvl.label }}</button>
{% endfor %} {% endfor %}
<button type="submit" name="action" value="missing">Missing</button> <button type="submit" name="action" value="missing">Missing</button>
<button type="submit" name="action" value="skip">Skip</button> <button type="submit" name="action" value="skip">Skip</button>

View File

@@ -19,6 +19,9 @@
</td> </td>
<td> <td>
{{ c.student.name }} {{ c.student.name }}
{% if c.student.pronouns %}
<span class="muted">{{ c.student.pronouns }}</span>
{% endif %}
{% if c.student.sortable_name and c.student.sortable_name != c.student.name %} {% if c.student.sortable_name and c.student.sortable_name != c.student.name %}
<span class="muted">({{ c.student.sortable_name }})</span> <span class="muted">({{ c.student.sortable_name }})</span>
{% endif %} {% endif %}

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Report — {{ course.title or "Cold Call" }}{% endblock %}
{% block body %}
<p class="no-print"><a href="{{ url_for('instructor.home') }}">&larr; back</a></p>
<h1>Participation report</h1>
<p>
{{ total_questions }} questions asked over {{ by_day|length }}
class day{{ "s" if by_day|length != 1 }}.
{% for k in outcome_order if outcome_totals.get(k) %}
{{ outcome_totals[k] }} {{ k }}{{ "," if not loop.last else "." }}
{% endfor %}
</p>
<h2>Questions answered per student</h2>
<div class="hist">
{% for bin in calls_hist.bins %}
<div class="bin">
<div class="bar-n muted">{{ bin.students or "" }}</div>
<div class="bar" style="height: {{ (bin.students / calls_hist.max_students * 100) | round }}%"></div>
<div class="bar-x">{{ bin.value }}</div>
</div>
{% endfor %}
</div>
<p class="muted">Students by number of questions answered.</p>
<h2>Opt-outs per student</h2>
<div class="hist">
{% for bin in optouts_hist.bins %}
<div class="bin">
<div class="bar-n muted">{{ bin.students or "" }}</div>
<div class="bar" style="height: {{ (bin.students / optouts_hist.max_students * 100) | round }}%"></div>
<div class="bar-x">{{ bin.value }}</div>
</div>
{% endfor %}
</div>
<p class="muted">Students by number of opt-outs.</p>
{% if by_day %}
<h2>Outcomes by class day</h2>
<table class="daybars">
{% for d in by_day %}
<tr>
<td class="muted">{{ d.day.isoformat() }}</td>
<td class="muted">{{ d.total }}</td>
<td>
<div class="stack">
{% for k in outcome_order if d.counts.get(k) %}
<div class="seg seg-{{ loop.index0 }} o-{{ k | lower | replace(' ', '-') | replace('(', '') | replace(')', '') }}"
style="width: {{ (d.counts[k] / d.total * 100) | round(1) }}%"
title="{{ k }}: {{ d.counts[k] }}"></div>
{% endfor %}
</div>
</td>
</tr>
{% endfor %}
</table>
<p class="muted">
{% for k in outcome_order if outcome_totals.get(k) %}
<span class="key o-{{ k | lower | replace(' ', '-') | replace('(', '') | replace(')', '') }}"></span> {{ k }}&nbsp;&nbsp;
{% endfor %}
</p>
{% endif %}
<h2>Students</h2>
<p class="no-print muted">
Sort by:
<a href="{{ url_for('.report', sort='name') }}">name</a> ·
<a href="{{ url_for('.report', sort='answered') }}">answered</a> ·
<a href="{{ url_for('.report', sort='missing') }}">missing</a> ·
<a href="{{ url_for('.report', sort='optouts') }}">opt-outs</a> ·
<a href="{{ url_for('.report', sort='prop') }}">share asked</a>
</p>
<table>
<tr>
<th>Student</th><th>Answered</th><th>Missing</th>
<th>Opt-outs</th><th>Share asked</th><th></th>
</tr>
{% for r in rows %}
<tr>
<td>{{ r.student.name }}</td>
<td>{{ r.answered }}</td>
<td>{{ r.missing or "" }}</td>
<td>{{ r.optouts or "" }}</td>
<td>
{% if r.prop_asked is not none %}
{{ "%.1f" | format(r.prop_asked * 100) }}%
<span class="muted">of {{ r.questions_present }}</span>
{% endif %}
</td>
<td class="muted">
{% if r.answered == 0 and r.missing == 0 %}never called{% endif %}
</td>
</tr>
{% endfor %}
</table>
<p class="no-print">
Export:
<a href="{{ url_for('.export_students') }}">students.csv</a> ·
<a href="{{ url_for('.export_calls') }}">calls.csv</a> ·
<a href="{{ url_for('.export_optouts') }}">optouts.csv</a>
</p>
{% endblock %}

View File

@@ -7,10 +7,14 @@
form only offers these dates. With no days listed, students can pick form only offers these dates. With no days listed, students can pick
any date.</p> any date.</p>
{% set dmin = course.start_date.isoformat() if course.start_date else "" %}
{% set dmax = course.end_date.isoformat() if course.end_date else "" %}
<h2>Add a range</h2> <h2>Add a range</h2>
<form method="post" action="{{ url_for('instructor.schedule_generate') }}"> <form method="post" action="{{ url_for('instructor.schedule_generate') }}">
<label>From <input type="date" name="start" required></label> <label>From <input type="date" name="start" required
<label>to <input type="date" name="end" required></label> value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"></label>
<label>to <input type="date" name="end" required
value="{{ dmax }}" min="{{ dmin }}" max="{{ dmax }}"></label>
{% for i, name in weekdays %} {% for i, name in weekdays %}
<label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label> <label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label>
{% endfor %} {% endfor %}
@@ -19,7 +23,8 @@
<h2>Add a single day</h2> <h2>Add a single day</h2>
<form method="post" action="{{ url_for('instructor.schedule_add') }}"> <form method="post" action="{{ url_for('instructor.schedule_add') }}">
<label>Date <input type="date" name="date" required></label> <label>Date <input type="date" name="date" required
min="{{ dmin }}" max="{{ dmax }}"></label>
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>

View File

@@ -29,6 +29,40 @@
{{ "checked" if course.show_assessments }}> {{ "checked" if course.show_assessments }}>
Students can see their own assessments</label> Students can see their own assessments</label>
</p> </p>
<h2>Assessment scale</h2>
<p class="muted">Each level has a label and points out of 100, used
when computing participation grades. Renaming a level renames it on
every past call too; levels already used by recorded calls cannot
be deleted. Lower position numbers sort first.</p>
<table>
<tr><th>Position</th><th>Label</th><th>Points</th><th>Delete</th></tr>
{% for lvl in levels %}
<tr>
<td><input type="number" name="position-{{ lvl.id }}"
value="{{ lvl.position }}" style="width: 4rem"></td>
<td><input type="text" name="label-{{ lvl.id }}" value="{{ lvl.label }}"></td>
<td><input type="number" name="points-{{ lvl.id }}" step="0.01"
min="0" max="100" value="{{ lvl.points }}"></td>
<td>
{% if level_use.get(lvl.id) %}
<span class="muted">in use ({{ level_use[lvl.id] }}
call{{ "s" if level_use[lvl.id] != 1 }})</span>
{% else %}
<input type="checkbox" name="delete-{{ lvl.id }}">
{% endif %}
</td>
</tr>
{% endfor %}
<tr>
<td class="muted">new</td>
<td><input type="text" name="new_label" placeholder="Label"></td>
<td><input type="number" name="new_points" step="0.01"
min="0" max="100" placeholder="Points"></td>
<td></td>
</tr>
</table>
<p><button type="submit">Save</button></p> <p><button type="submit">Save</button></p>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -2,7 +2,10 @@
{% block title %}{{ course.title or "Cold Call" }}{% endblock %} {% block title %}{{ course.title or "Cold Call" }}{% endblock %}
{% 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 }}.</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
they are not right, update them in your Canvas account settings and
they will be picked up here automatically.</p>
<h2>Your standing</h2> <h2>Your standing</h2>
<p> <p>

View File

@@ -49,6 +49,9 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
# SQLite cannot ALTER constraints in place; batch mode uses the
# copy-and-move strategy and is harmless on MariaDB/MySQL.
render_as_batch=True,
) )
with context.begin_transaction(): with context.begin_transaction():
@@ -70,7 +73,9 @@ def run_migrations_online() -> None:
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( context.configure(
connection=connection, target_metadata=target_metadata connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
) )
with context.begin_transaction(): with context.begin_transaction():

View File

@@ -0,0 +1,30 @@
"""student pronouns
Revision ID: 1ec230be46bd
Revises: 37acdb847a25
Create Date: 2026-07-31 16:47:53.477882
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1ec230be46bd'
down_revision: Union[str, None] = '37acdb847a25'
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.add_column('students', sa.Column('pronouns', sa.String(length=64), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('students', 'pronouns')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""course term dates
Revision ID: 487d155678ea
Revises: 1ec230be46bd
Create Date: 2026-07-31 16:50:52.938563
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '487d155678ea'
down_revision: Union[str, None] = '1ec230be46bd'
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.add_column('courses', sa.Column('start_date', sa.Date(), nullable=True))
op.add_column('courses', sa.Column('end_date', sa.Date(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('courses', 'end_date')
op.drop_column('courses', 'start_date')
# ### end Alembic commands ###

View File

@@ -0,0 +1,78 @@
"""assessment levels and nrps details
Revision ID: 826e2835e289
Revises: 487d155678ea
Create Date: 2026-07-31 16:56:00.716357
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '826e2835e289'
down_revision: Union[str, None] = '487d155678ea'
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! ###
with op.batch_alter_table('assessment_levels', schema=None) as batch_op:
batch_op.create_unique_constraint(batch_op.f('uq_assessment_levels_course_id'), ['course_id', 'label'])
with op.batch_alter_table('calls', schema=None) as batch_op:
batch_op.create_foreign_key(batch_op.f('fk_calls_assessment_id_assessment_levels'), 'assessment_levels', ['assessment_id'], ['id'])
batch_op.drop_column('assessment')
with op.batch_alter_table('class_days', schema=None) as batch_op:
batch_op.create_unique_constraint(batch_op.f('uq_class_days_course_id'), ['course_id', 'date'])
with op.batch_alter_table('courses', schema=None) as batch_op:
batch_op.add_column(sa.Column('lti_issuer', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('lti_client_id', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('nrps_url', sa.String(length=1024), nullable=True))
batch_op.create_unique_constraint(batch_op.f('uq_courses_lti_context_id'), ['lti_context_id'])
with op.batch_alter_table('enrollments', schema=None) as batch_op:
batch_op.create_unique_constraint(batch_op.f('uq_enrollments_course_id'), ['course_id', 'student_id'])
with op.batch_alter_table('optouts', schema=None) as batch_op:
batch_op.create_unique_constraint(batch_op.f('uq_optouts_course_id'), ['course_id', 'student_id', 'date'])
with op.batch_alter_table('students', schema=None) as batch_op:
batch_op.create_unique_constraint(batch_op.f('uq_students_canvas_user_id'), ['canvas_user_id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('students', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('uq_students_canvas_user_id'), type_='unique')
with op.batch_alter_table('optouts', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('uq_optouts_course_id'), type_='unique')
with op.batch_alter_table('enrollments', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('uq_enrollments_course_id'), type_='unique')
with op.batch_alter_table('courses', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('uq_courses_lti_context_id'), type_='unique')
batch_op.drop_column('nrps_url')
batch_op.drop_column('lti_client_id')
batch_op.drop_column('lti_issuer')
with op.batch_alter_table('class_days', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('uq_class_days_course_id'), type_='unique')
with op.batch_alter_table('calls', schema=None) as batch_op:
batch_op.add_column(sa.Column('assessment', sa.VARCHAR(length=32), nullable=True))
batch_op.drop_constraint(batch_op.f('fk_calls_assessment_id_assessment_levels'), type_='foreignkey')
with op.batch_alter_table('assessment_levels', schema=None) as batch_op:
batch_op.drop_constraint(batch_op.f('uq_assessment_levels_course_id'), type_='unique')
# ### end Alembic commands ###

View File

@@ -32,3 +32,17 @@ def make_app(tmp_path, dev_mode=True):
@pytest.fixture @pytest.fixture
def dev_client(tmp_path): def dev_client(tmp_path):
return make_app(tmp_path, dev_mode=True).test_client() return make_app(tmp_path, dev_mode=True).test_client()
def outcome_actions(page):
"""Map outcome button labels to their form values on a live page,
e.g. {'GOOD': 'level-1', ..., 'Missing': 'missing'}."""
import re
return {
label.strip(): value
for value, label in re.findall(
r'<button type="submit" name="action" value="([^"]+)">([^<]+)</button>',
page,
)
}

30
tests/test_cli.py Normal file
View File

@@ -0,0 +1,30 @@
import datetime
from sqlalchemy import select
from coldcall_lti.models import Course
from conftest import make_app
def test_sync_rosters_skips_unsyncable_and_ended_courses(tmp_path):
app = make_app(tmp_path, dev_mode=True)
client = app.test_client()
client.post("/dev/launch/dev-instructor") # creates the dev course
with app.app_context():
db = app.extensions["db_session_factory"]()
ended = Course(
lti_context_id="old-course",
title="Old Course",
nrps_url="https://example.edu/nrps",
end_date=datetime.date.today() - datetime.timedelta(days=90),
)
db.add(ended)
db.commit()
db.close()
result = app.test_cli_runner().invoke(args=["sync-rosters"])
assert result.exit_code == 0
# The dev course has no NRPS service; the old course has ended.
assert "no roster service recorded, skipped" in result.output
assert "Old Course: ended, skipped" in result.output

View File

@@ -3,6 +3,8 @@ import re
import pytest import pytest
from conftest import outcome_actions
TODAY = datetime.date.today().isoformat() TODAY = datetime.date.today().isoformat()
@@ -30,7 +32,7 @@ def test_live_flow_records_outcome(instructor):
call_id = match.group(1) call_id = match.group(1)
resp = instructor.post( resp = instructor.post(
f"/instructor/call/{call_id}/outcome", f"/instructor/call/{call_id}/outcome",
data={"action": "GOOD"}, data={"action": outcome_actions(page)["GOOD"]},
follow_redirects=True, follow_redirects=True,
) )
page = resp.get_data(as_text=True) page = resp.get_data(as_text=True)
@@ -76,12 +78,13 @@ def test_day_edit_saves_outcomes(instructor):
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True) page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
ids = re.findall(r'name="status-(\d+)"', page) ids = re.findall(r'name="status-(\d+)"', page)
assert len(ids) == 2 assert len(ids) == 2
poor_id = re.search(r'value="(\d+)"[^>]*>POOR</option>', page).group(1)
resp = instructor.post( resp = instructor.post(
f"/instructor/day/{TODAY}", f"/instructor/day/{TODAY}",
data={ data={
f"status-{ids[0]}": "answered", f"status-{ids[0]}": "answered",
f"assessment-{ids[0]}": "POOR", f"assessment-{ids[0]}": poor_id,
f"note-{ids[0]}": "rough day", f"note-{ids[0]}": "rough day",
f"status-{ids[1]}": "pending", f"status-{ids[1]}": "pending",
f"delete-{ids[1]}": "on", f"delete-{ids[1]}": "on",
@@ -103,11 +106,56 @@ def test_outcome_rejects_bad_action_and_foreign_call(instructor):
) )
assert resp.status_code == 400 assert resp.status_code == 400
resp = instructor.post( resp = instructor.post(
"/instructor/call/99999/outcome", data={"action": "GOOD"} "/instructor/call/99999/outcome", data={"action": "missing"}
) )
assert resp.status_code == 404 assert resp.status_code == 404
def test_assessment_scale_editing(instructor):
page = instructor.get("/instructor/settings").get_data(as_text=True)
ids = {
label: lid
for lid, label in re.findall(
r'name="label-(\d+)" value="([^"]+)"', page
)
}
assert set(ids) == {"GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER"}
# Record a call assessed GOOD so that level is in use.
record = instructor.post(
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
).get_data(as_text=True)
call_id = re.search(r"/instructor/call/(\d+)/outcome", record).group(1)
instructor.post(
f"/instructor/call/{call_id}/outcome",
data={"action": f"level-{ids['GOOD']}"},
)
# Rename and re-point the in-use level; delete an unused one; add one.
form = {"selection_mode": "weighted", "weight_factor": "2",
"show_assessments": "on",
"new_label": "HEROIC", "new_points": "110"}
for label, lid in ids.items():
form[f"label-{lid}"] = "EXCELLENT" if label == "GOOD" else label
form[f"points-{lid}"] = "90" if label == "GOOD" else "50"
form[f"position-{lid}"] = "0"
form[f"delete-{ids['POOR']}"] = "on"
form[f"delete-{ids['GOOD']}"] = "on" # in use: must survive
page = instructor.post(
"/instructor/settings", data=form, follow_redirects=True
).get_data(as_text=True)
assert 'value="EXCELLENT"' in page # renamed
assert 'value="90.0"' in page # re-pointed
assert "POOR" not in page # unused level deleted
assert re.search(r"in use \(1\s+call\)", page) # used level kept
assert "HEROIC" not in page # >100 points rejected
# The rename follows through to recorded calls.
day_page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
assert "EXCELLENT" in day_page
def test_settings_roundtrip(instructor): def test_settings_roundtrip(instructor):
resp = instructor.post( resp = instructor.post(
"/instructor/settings", "/instructor/settings",

View File

@@ -28,6 +28,26 @@ def test_extract_launch_info_instructor():
assert info.is_instructor assert info.is_instructor
def test_extract_launch_info_pronouns_custom_claim():
data = sample_launch_data()
data[launch.CLAIM_CUSTOM] = {"pronouns": "ze/zir"}
assert launch.extract_launch_info(data).pronouns == "ze/zir"
data[launch.CLAIM_CUSTOM] = {"pronouns": "$com.instructure.Person.pronouns"}
assert launch.extract_launch_info(data).pronouns is None
def test_extract_launch_info_course_dates():
data = sample_launch_data()
data[launch.CLAIM_CUSTOM] = {
"course_start": "2026-09-30T07:00:00Z",
"course_end": "$Canvas.course.endAt",
}
info = launch.extract_launch_info(data)
assert info.course_start == launch.datetime.date(2026, 9, 30)
assert info.course_end is None
def test_upsert_launch_creates_and_is_idempotent(db_session): def test_upsert_launch_creates_and_is_idempotent(db_session):
info = launch.extract_launch_info(sample_launch_data()) info = launch.extract_launch_info(sample_launch_data())
course, student = launch.upsert_launch(db_session, info) course, student = launch.upsert_launch(db_session, info)

81
tests/test_report_ui.py Normal file
View File

@@ -0,0 +1,81 @@
import datetime
import re
import pytest
from conftest import outcome_actions
TODAY = datetime.date.today().isoformat()
@pytest.fixture
def instructor(dev_client):
dev_client.post("/dev/launch/dev-instructor")
return dev_client
def record_one_call(client, action="GOOD"):
client.post("/instructor/live/next", data={"date": TODAY})
page = client.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
actions = outcome_actions(page)
value = actions.get(action) or actions[action.title()]
client.post(f"/instructor/call/{call_id}/outcome", data={"action": value})
def test_report_page_renders(instructor):
record_one_call(instructor, "GOOD")
record_one_call(instructor, "POOR")
record_one_call(instructor, "missing")
page = instructor.get("/instructor/report").get_data(as_text=True)
assert "3 questions asked over 1" in page
assert "never called" in page
assert 'class="hist"' in page
assert "Outcomes by class day" in page
def test_report_sorting(instructor):
record_one_call(instructor, "GOOD")
page = instructor.get("/instructor/report?sort=answered").get_data(as_text=True)
rows = re.findall(r"<td>([A-Z][a-z]+ [A-Za-z]+)</td>", page)
# The called student sorts first under the answered sort.
called = re.search(r"never called", page)
assert rows, "expected student rows"
assert called
def test_csv_exports(instructor):
record_one_call(instructor, "GOOD")
resp = instructor.get("/instructor/export/students.csv")
assert resp.mimetype == "text/csv"
lines = resp.get_data(as_text=True).strip().splitlines()
assert lines[0].startswith("name,")
assert len(lines) == 9 # header + 8 students
resp = instructor.get("/instructor/export/calls.csv")
lines = resp.get_data(as_text=True).strip().splitlines()
assert len(lines) == 2
assert ",answered,GOOD," in lines[1]
resp = instructor.get("/instructor/export/optouts.csv")
assert resp.get_data(as_text=True).strip().splitlines()[0].startswith("date,")
def test_report_requires_instructor(dev_client):
dev_client.post("/dev/launch/dev-instructor")
dev_client.post("/dev/launch/dev-student-1")
assert dev_client.get("/instructor/report").status_code == 403
assert dev_client.get("/instructor/export/calls.csv").status_code == 403
def test_pronouns_flow_to_pages(instructor):
# Dev roster carries pronouns; they should reach the print list.
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "30"})
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
assert "she/her" in page or "he/him" in page
instructor.post("/dev/launch/dev-student-1")
page = instructor.get("/me").get_data(as_text=True)
assert "Ada Lovelace (she/her)" in page
assert "drawn from Canvas" in page

95
tests/test_reports.py Normal file
View File

@@ -0,0 +1,95 @@
import datetime
from coldcall_lti import models, reports
D1 = datetime.date(2026, 10, 1)
D2 = datetime.date(2026, 10, 3)
def setup_course(db):
course = models.Course(lti_context_id="ctx-1")
db.add(course)
db.flush()
models.ensure_default_levels(db, course)
students = []
for i in range(3):
s = models.Student(canvas_user_id=f"u{i}", name=f"Student {i}")
db.add(s)
students.append(s)
db.flush()
for s in students:
db.add(models.Enrollment(course_id=course.id, student_id=s.id))
db.flush()
return course, students
def add_call(db, course, student, day, status, assessment=None):
level_ids = {
lvl.label: lvl.id
for lvl in models.assessment_levels(db, course.id)
}
db.add(
models.Call(
course_id=course.id,
student_id=student.id,
session_date=day,
status=status,
assessment_id=level_ids[assessment] if assessment else None,
)
)
db.flush()
def test_student_stats_fairness_denominator(db_session):
course, (a, b, c) = setup_course(db_session)
# Day 1: three questions; day 2: one question. b opted out day 1.
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "GOOD")
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "POOR")
add_call(db_session, course, c, D1, models.STATUS_MISSING)
add_call(db_session, course, b, D2, models.STATUS_ANSWERED, "GOOD")
# Skipped and pending calls count nowhere.
add_call(db_session, course, c, D2, models.STATUS_SKIPPED)
add_call(db_session, course, c, D2, models.STATUS_PENDING)
db_session.add(
models.OptOut(course_id=course.id, student_id=b.id, date=D1)
)
db_session.flush()
rows = {r["student"].id: r for r in reports.student_stats(db_session, course)}
assert rows[a.id]["answered"] == 2
assert rows[a.id]["questions_present"] == 4
assert rows[a.id]["prop_asked"] == 0.5
# b missed day 1's three questions by opting out.
assert rows[b.id]["questions_present"] == 1
assert rows[b.id]["prop_asked"] == 1.0
assert rows[b.id]["optouts"] == 1
assert rows[c.id]["answered"] == 0
assert rows[c.id]["missing"] == 1
assert rows[c.id]["prop_asked"] == 0.0
def test_assessments_by_day(db_session):
course, (a, b, c) = setup_course(db_session)
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "GOOD")
add_call(db_session, course, b, D1, models.STATUS_ANSWERED, "GOOD")
add_call(db_session, course, c, D1, models.STATUS_MISSING)
add_call(db_session, course, a, D2, models.STATUS_ANSWERED, "SATISFACTORY")
by_day = reports.assessments_by_day(db_session, course.id)
assert [d["day"] for d in by_day] == [D1, D2]
assert by_day[0]["counts"] == {"GOOD": 2, "missing": 1}
assert by_day[0]["total"] == 3
assert by_day[1]["counts"] == {"SATISFACTORY": 1}
def test_histogram_includes_zero_bin():
h = reports.histogram([0, 0, 2])
assert h["bins"] == [
{"value": 0, "students": 2},
{"value": 1, "students": 0},
{"value": 2, "students": 1},
]
assert h["max_students"] == 2

View File

@@ -74,6 +74,28 @@ def test_sync_skips_inactive_members(db_session):
assert db_session.query(models.Student).count() == 1 assert db_session.query(models.Student).count() == 1
def test_sync_extracts_pronouns_from_nrps_message(db_session):
course = make_course(db_session)
custom_claim = "https://purl.imsglobal.org/spec/lti/claim/custom"
sync_roster(
db_session,
course,
[
member("u1", message=[{custom_claim: {"pronouns": "they/them"}}]),
# Unexpanded variable (platform without pronouns) is ignored.
member(
"u2",
message=[{custom_claim: {"pronouns": "$com.instructure.Person.pronouns"}}],
),
],
)
students = {
s.canvas_user_id: s for s in db_session.query(models.Student).all()
}
assert students["u1"].pronouns == "they/them"
assert students["u2"].pronouns is None
def test_sync_updates_changed_names(db_session): def test_sync_updates_changed_names(db_session):
course = make_course(db_session) course = make_course(db_session)
sync_roster(db_session, course, [member("u1", "Old Name")]) sync_roster(db_session, course, [member("u1", "Old Name")])

View File

@@ -3,6 +3,8 @@ import re
import pytest import pytest
from conftest import outcome_actions
TODAY = datetime.date.today() TODAY = datetime.date.today()
@@ -97,7 +99,8 @@ def test_assessment_visibility_setting(student):
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1) call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
target = "Ada Lovelace" in page target = "Ada Lovelace" in page
student.post( student.post(
f"/instructor/call/{call_id}/outcome", data={"action": "POOR"} f"/instructor/call/{call_id}/outcome",
data={"action": outcome_actions(page)["POOR"]},
) )
if target: if target:
break break