diff --git a/README.md b/README.md index 0eb833a..2edcfbe 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/coldcall_lti/__init__.py b/coldcall_lti/__init__.py index ad3098a..54d9483 100644 --- a/coldcall_lti/__init__.py +++ b/coldcall_lti/__init__.py @@ -34,6 +34,10 @@ def create_app(config=Config): app.register_blueprint(dev.bp) + from .cli import register_cli + + register_cli(app) + @app.get("/healthz") def healthz(): return {"status": "ok"} diff --git a/coldcall_lti/cli.py b/coldcall_lti/cli.py new file mode 100644 index 0000000..8c44657 --- /dev/null +++ b/coldcall_lti/cli.py @@ -0,0 +1,50 @@ +"""Management commands, run as `flask --app coldcall_lti `. + +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() diff --git a/coldcall_lti/db.py b/coldcall_lti/db.py index 0075d5b..61ea08c 100644 --- a/coldcall_lti/db.py +++ b/coldcall_lti/db.py @@ -1,10 +1,20 @@ from flask import current_app, g -from sqlalchemy import create_engine +from sqlalchemy import MetaData, create_engine 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): - pass + metadata = MetaData(naming_convention=NAMING_CONVENTION) def make_engine(url, echo=False): diff --git a/coldcall_lti/dev.py b/coldcall_lti/dev.py index 8a30721..287c70d 100644 --- a/coldcall_lti/dev.py +++ b/coldcall_lti/dev.py @@ -14,7 +14,7 @@ from sqlalchemy import select from .db import get_db 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 bp = Blueprint("dev", __name__, url_prefix="/dev") @@ -28,6 +28,7 @@ DEV_MEMBERS = [ "given_name": "Ida", "family_name": "Instructor", "email": "instructor@example.edu", + "pronouns": "she/her", "roles": [ROLE_INSTRUCTOR], "status": "Active", } @@ -38,19 +39,20 @@ DEV_MEMBERS = [ "given_name": given, "family_name": family, "email": f"{given.lower()}@example.edu", + "pronouns": pronouns, "roles": [ROLE_LEARNER], "status": "Active", } - for i, (given, family) in enumerate( + for i, (given, family, pronouns) in enumerate( [ - ("Ada", "Lovelace"), - ("Grace", "Hopper"), - ("Alan", "Turing"), - ("Annie", "Easley"), - ("Edsger", "Dijkstra"), - ("Katherine", "Johnson"), - ("Donald", "Knuth"), - ("Radia", "Perlman"), + ("Ada", "Lovelace", "she/her"), + ("Grace", "Hopper", "she/her"), + ("Alan", "Turing", "he/him"), + ("Annie", "Easley", "she/her"), + ("Edsger", "Dijkstra", None), + ("Katherine", "Johnson", "she/her"), + ("Donald", "Knuth", "he/him"), + ("Radia", "Perlman", None), ], start=1, ) @@ -62,9 +64,16 @@ def _ensure_dev_course(db): select(Course).where(Course.lti_context_id == DEV_CONTEXT_ID) ).scalar_one_or_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.flush() + ensure_default_levels(db, course) sync_roster(db, course, DEV_MEMBERS) # A Tue/Thu meeting pattern for the next few weeks, so the opt-out diff --git a/coldcall_lti/instructor.py b/coldcall_lti/instructor.py index b167e1f..ab30302 100644 --- a/coldcall_lti/instructor.py +++ b/coldcall_lti/instructor.py @@ -1,8 +1,12 @@ +import csv import datetime +import io from flask import ( Blueprint, + Response, abort, + current_app, redirect, render_template, request, @@ -10,24 +14,26 @@ from flask import ( ) from sqlalchemy import case, func, select -from . import calls +from . import calls, reports from .db import get_db from .models import ( - ASSESSMENTS, CALL_STATUSES, SELECTION_CYCLE, SELECTION_WEIGHTED, STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, + AssessmentLevel, Call, ClassDay, Enrollment, OptOut, Student, + assessment_levels, class_days, students_present, ) +from .roster import sync_roster from .views import current_course, require_instructor bp = Blueprint("instructor", __name__, url_prefix="/instructor") @@ -106,7 +112,11 @@ def live(): if c.status != calls.STATUS_PENDING ] 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) @@ -130,15 +140,18 @@ def call_outcome(call_id): call = _get_course_call(db, call_id) 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.assessment = action + call.assessment_id = level.id elif action == "skip": call.status = STATUS_SKIPPED - call.assessment = None + call.assessment_id = None elif action == "missing": call.status = STATUS_MISSING - call.assessment = None + call.assessment_id = None else: abort(400, "Unknown outcome.") db.commit() @@ -182,7 +195,7 @@ def day_edit(date_str): context = _day_context(db, course, day) context.update( day_calls=calls.calls_for_day(db, course.id, day), - assessments=ASSESSMENTS, + levels=assessment_levels(db, course.id), statuses=CALL_STATUSES, ) return render_template("day_edit.html", **context) @@ -195,6 +208,7 @@ def day_save(date_str): course = current_course() 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): if request.form.get(f"delete-{call.id}"): db.delete(call) @@ -202,14 +216,141 @@ def day_save(date_str): status = request.form.get(f"status-{call.id}") if status in CALL_STATUSES: call.status = status - assessment = request.form.get(f"assessment-{call.id}", "") - call.assessment = assessment if assessment in ASSESSMENTS else None + assessment = request.form.get(f"assessment-{call.id}", type=int) + call.assessment_id = assessment if assessment in level_ids else None note = request.form.get(f"note-{call.id}", "").strip() call.note = note or None db.commit() 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") @require_instructor def schedule(): @@ -273,16 +414,53 @@ def schedule_delete(day_id): 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") @require_instructor def settings(): + db = get_db() + course = current_course() return render_template( "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), ) +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") @require_instructor def settings_save(): @@ -296,5 +474,37 @@ def settings_save(): if weight and weight >= 1.0: course.weight_factor = weight 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() return redirect(url_for(".settings")) diff --git a/coldcall_lti/launch.py b/coldcall_lti/launch.py index a301979..c7666da 100644 --- a/coldcall_lti/launch.py +++ b/coldcall_lti/launch.py @@ -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. """ +import datetime from dataclasses import dataclass 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_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" @@ -27,6 +50,10 @@ class LaunchInfo: 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 @@ -36,6 +63,7 @@ def roles_include_instructor(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), @@ -44,6 +72,10 @@ def extract_launch_info(launch_data): 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, [])), ) @@ -56,10 +88,16 @@ def upsert_launch(db, info): 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) @@ -73,6 +111,8 @@ def upsert_launch(db, info): student.email = info.email if info.picture: student.avatar_url = info.picture + if info.pronouns: + student.pronouns = info.pronouns db.flush() enrollment = db.execute( diff --git a/coldcall_lti/lti.py b/coldcall_lti/lti.py index 436e9b5..cf6a063 100644 --- a/coldcall_lti/lti.py +++ b/coldcall_lti/lti.py @@ -8,12 +8,35 @@ from pylti1p3.contrib.flask import ( ) from pylti1p3.tool_config import ToolConfJsonFile +from pylti1p3.names_roles import NamesRolesProvisioningService +from pylti1p3.service_connector import ServiceConnector + from .db import get_db from .launch import extract_launch_info, upsert_launch from .roster import sync_roster 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(): return ToolConfJsonFile(current_app.config["LTI_CONFIG_PATH"]) @@ -50,10 +73,23 @@ def launch(): db = get_db() 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 # picked up before every class without a separate sync step. 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) db.commit() diff --git a/coldcall_lti/models.py b/coldcall_lti/models.py index 867992a..8c69ed3 100644 --- a/coldcall_lti/models.py +++ b/coldcall_lti/models.py @@ -32,8 +32,16 @@ STATUS_SKIPPED = "skipped" STATUS_PENDING = "pending" CALL_STATUSES = (STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, STATUS_PENDING) -# Default assessment vocabulary, carried over from the manual system. -ASSESSMENTS = ("GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER") +# Default assessment vocabulary and points, carried over from the +# 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_CYCLE = "cycle" @@ -47,6 +55,18 @@ class Course(Base): lti_deployment_id: 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. weight_factor: Mapped[float] = mapped_column(Float, default=2.0) selection_mode: Mapped[str] = mapped_column( @@ -70,6 +90,7 @@ class Student(Base): 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)) + pronouns: Mapped[str | None] = mapped_column(String(64)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student") @@ -90,6 +111,50 @@ class Enrollment(Base): 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): """A day the class actually meets. When a course has these, the 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")) session_date: Mapped[datetime.date] = mapped_column(Date) 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) created_at: Mapped[datetime.datetime] = mapped_column( DateTime, default=utcnow ) 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): diff --git a/coldcall_lti/reports.py b/coldcall_lti/reports.py new file mode 100644 index 0000000..9f9979e --- /dev/null +++ b/coldcall_lti/reports.py @@ -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()), + } diff --git a/coldcall_lti/roster.py b/coldcall_lti/roster.py index 7e4d23e..36fcdd7 100644 --- a/coldcall_lti/roster.py +++ b/coldcall_lti/roster.py @@ -7,9 +7,24 @@ dev-mode fake roster. from sqlalchemy import select +from .launch import CLAIM_CUSTOM, clean_custom_value 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): 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"] if member.get("picture"): student.avatar_url = member["picture"] + pronouns = _member_pronouns(member) + if pronouns: + student.pronouns = pronouns db.flush() seen_student_ids.add(student.id) diff --git a/coldcall_lti/templates/base.html b/coldcall_lti/templates/base.html index 4d86176..5cdfbfe 100644 --- a/coldcall_lti/templates/base.html +++ b/coldcall_lti/templates/base.html @@ -12,6 +12,7 @@ .muted { color: #666; } .callcard { margin: 1.5rem 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.big { font-size: 1.5rem; padding: 1rem 2rem; } .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-n { font-size: 0.75rem; } .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 { .no-print { display: none; } body { margin: 0.5rem; max-width: none; } diff --git a/coldcall_lti/templates/day_edit.html b/coldcall_lti/templates/day_edit.html index 56afc64..f2b1545 100644 --- a/coldcall_lti/templates/day_edit.html +++ b/coldcall_lti/templates/day_edit.html @@ -26,8 +26,8 @@ diff --git a/coldcall_lti/templates/instructor_home.html b/coldcall_lti/templates/instructor_home.html index 51a812a..a6aa918 100644 --- a/coldcall_lti/templates/instructor_home.html +++ b/coldcall_lti/templates/instructor_home.html @@ -10,10 +10,17 @@

Live cold call · Today's calls · + Report · Schedule · Settings

+
+ + The roster also refreshes automatically every + time you open this tool from Canvas. +
+

Printable list

diff --git a/coldcall_lti/templates/live.html b/coldcall_lti/templates/live.html index bf41de5..e6929dc 100644 --- a/coldcall_lti/templates/live.html +++ b/coldcall_lti/templates/live.html @@ -13,15 +13,19 @@ {% if call.student.avatar_url %} {% endif %} -

{{ call.student.name }}

+

{{ call.student.name }} + {% if call.student.pronouns %} + ({{ call.student.pronouns }}) + {% endif %} +

{% if call.student.sortable_name %}

{{ call.student.sortable_name }}

{% endif %} - {% for a in assessments %} - + {% for lvl in levels %} + {% endfor %} diff --git a/coldcall_lti/templates/print_list.html b/coldcall_lti/templates/print_list.html index 1d15734..a5b2abf 100644 --- a/coldcall_lti/templates/print_list.html +++ b/coldcall_lti/templates/print_list.html @@ -19,6 +19,9 @@ {{ c.student.name }} + {% if c.student.pronouns %} + {{ c.student.pronouns }} + {% endif %} {% if c.student.sortable_name and c.student.sortable_name != c.student.name %} ({{ c.student.sortable_name }}) {% endif %} diff --git a/coldcall_lti/templates/report.html b/coldcall_lti/templates/report.html new file mode 100644 index 0000000..8ce371b --- /dev/null +++ b/coldcall_lti/templates/report.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}Report — {{ course.title or "Cold Call" }}{% endblock %} +{% block body %} +

← back

+

Participation report

+ +

+ {{ 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 %} +

+ +

Questions answered per student

+
+ {% for bin in calls_hist.bins %} +
+
{{ bin.students or "" }}
+
+
{{ bin.value }}
+
+ {% endfor %} +
+

Students by number of questions answered.

+ +

Opt-outs per student

+
+ {% for bin in optouts_hist.bins %} +
+
{{ bin.students or "" }}
+
+
{{ bin.value }}
+
+ {% endfor %} +
+

Students by number of opt-outs.

+ +{% if by_day %} +

Outcomes by class day

+ + {% for d in by_day %} + + + + + + {% endfor %} +
{{ d.day.isoformat() }}{{ d.total }} +
+ {% for k in outcome_order if d.counts.get(k) %} +
+ {% endfor %} +
+
+

+ {% for k in outcome_order if outcome_totals.get(k) %} + {{ k }}   + {% endfor %} +

+{% endif %} + +

Students

+

+ Sort by: + name · + answered · + missing · + opt-outs · + share asked +

+ + + + + + {% for r in rows %} + + + + + + + + + {% endfor %} +
StudentAnsweredMissingOpt-outsShare asked
{{ r.student.name }}{{ r.answered }}{{ r.missing or "" }}{{ r.optouts or "" }} + {% if r.prop_asked is not none %} + {{ "%.1f" | format(r.prop_asked * 100) }}% + of {{ r.questions_present }} + {% endif %} + + {% if r.answered == 0 and r.missing == 0 %}never called{% endif %} +
+ +

+ Export: + students.csv · + calls.csv · + optouts.csv +

+{% endblock %} diff --git a/coldcall_lti/templates/schedule.html b/coldcall_lti/templates/schedule.html index ea9ed49..47ca44a 100644 --- a/coldcall_lti/templates/schedule.html +++ b/coldcall_lti/templates/schedule.html @@ -7,10 +7,14 @@ form only offers these dates. With no days listed, students can pick any date.

+{% set dmin = course.start_date.isoformat() if course.start_date else "" %} +{% set dmax = course.end_date.isoformat() if course.end_date else "" %}

Add a range

- - + + {% for i, name in weekdays %} {% endfor %} @@ -19,7 +23,8 @@

Add a single day

- +
diff --git a/coldcall_lti/templates/settings.html b/coldcall_lti/templates/settings.html index c274e08..59162cd 100644 --- a/coldcall_lti/templates/settings.html +++ b/coldcall_lti/templates/settings.html @@ -29,6 +29,40 @@ {{ "checked" if course.show_assessments }}> Students can see their own assessments

+ +

Assessment scale

+

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.

+ + + {% for lvl in levels %} + + + + + + + {% endfor %} + + + + + + +
PositionLabelPointsDelete
+ {% if level_use.get(lvl.id) %} + in use ({{ level_use[lvl.id] }} + call{{ "s" if level_use[lvl.id] != 1 }}) + {% else %} + + {% endif %} +
new
+

{% endblock %} diff --git a/coldcall_lti/templates/student_home.html b/coldcall_lti/templates/student_home.html index 8e81481..23e54ee 100644 --- a/coldcall_lti/templates/student_home.html +++ b/coldcall_lti/templates/student_home.html @@ -2,7 +2,10 @@ {% block title %}{{ course.title or "Cold Call" }}{% endblock %} {% block body %}

{{ course.title or course.lti_context_id }}

-

Hi {{ user.name }}.

+

Hi {{ user.name }}{% if user.pronouns %} ({{ user.pronouns }}){% endif %}.

+

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.

Your standing

diff --git a/migrations/env.py b/migrations/env.py index 4b80bf8..9cf43f4 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -49,6 +49,9 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, 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(): @@ -70,7 +73,9 @@ def run_migrations_online() -> None: with connectable.connect() as connection: context.configure( - connection=connection, target_metadata=target_metadata + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, ) with context.begin_transaction(): diff --git a/migrations/versions/1ec230be46bd_student_pronouns.py b/migrations/versions/1ec230be46bd_student_pronouns.py new file mode 100644 index 0000000..c981626 --- /dev/null +++ b/migrations/versions/1ec230be46bd_student_pronouns.py @@ -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 ### diff --git a/migrations/versions/487d155678ea_course_term_dates.py b/migrations/versions/487d155678ea_course_term_dates.py new file mode 100644 index 0000000..d8a232a --- /dev/null +++ b/migrations/versions/487d155678ea_course_term_dates.py @@ -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 ### diff --git a/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py b/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py new file mode 100644 index 0000000..1c78e4f --- /dev/null +++ b/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py @@ -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 ### diff --git a/tests/conftest.py b/tests/conftest.py index 79d1ac3..84f2e9b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,3 +32,17 @@ def make_app(tmp_path, dev_mode=True): @pytest.fixture def dev_client(tmp_path): 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'', + page, + ) + } diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8eae663 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_instructor_ui.py b/tests/test_instructor_ui.py index 02e8a04..ea940eb 100644 --- a/tests/test_instructor_ui.py +++ b/tests/test_instructor_ui.py @@ -3,6 +3,8 @@ import re import pytest +from conftest import outcome_actions + TODAY = datetime.date.today().isoformat() @@ -30,7 +32,7 @@ def test_live_flow_records_outcome(instructor): call_id = match.group(1) resp = instructor.post( f"/instructor/call/{call_id}/outcome", - data={"action": "GOOD"}, + data={"action": outcome_actions(page)["GOOD"]}, follow_redirects=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) ids = re.findall(r'name="status-(\d+)"', page) assert len(ids) == 2 + poor_id = re.search(r'value="(\d+)"[^>]*>POOR', page).group(1) resp = instructor.post( f"/instructor/day/{TODAY}", data={ f"status-{ids[0]}": "answered", - f"assessment-{ids[0]}": "POOR", + f"assessment-{ids[0]}": poor_id, f"note-{ids[0]}": "rough day", f"status-{ids[1]}": "pending", f"delete-{ids[1]}": "on", @@ -103,11 +106,56 @@ def test_outcome_rejects_bad_action_and_foreign_call(instructor): ) assert resp.status_code == 400 resp = instructor.post( - "/instructor/call/99999/outcome", data={"action": "GOOD"} + "/instructor/call/99999/outcome", data={"action": "missing"} ) 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): resp = instructor.post( "/instructor/settings", diff --git a/tests/test_launch.py b/tests/test_launch.py index 5267151..1fb8901 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -28,6 +28,26 @@ def test_extract_launch_info_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): info = launch.extract_launch_info(sample_launch_data()) course, student = launch.upsert_launch(db_session, info) diff --git a/tests/test_report_ui.py b/tests/test_report_ui.py new file mode 100644 index 0000000..0914a48 --- /dev/null +++ b/tests/test_report_ui.py @@ -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"([A-Z][a-z]+ [A-Za-z]+)", 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 diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..8d836ed --- /dev/null +++ b/tests/test_reports.py @@ -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 diff --git a/tests/test_roster.py b/tests/test_roster.py index 708f058..6670a59 100644 --- a/tests/test_roster.py +++ b/tests/test_roster.py @@ -74,6 +74,28 @@ def test_sync_skips_inactive_members(db_session): 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): course = make_course(db_session) sync_roster(db_session, course, [member("u1", "Old Name")]) diff --git a/tests/test_student_ui.py b/tests/test_student_ui.py index 7ebd112..ab5d200 100644 --- a/tests/test_student_ui.py +++ b/tests/test_student_ui.py @@ -3,6 +3,8 @@ import re import pytest +from conftest import outcome_actions + 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) target = "Ada Lovelace" in page 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: break