From 7845167ec55df036d4d6209f8e18880eda80abc4 Mon Sep 17 00:00:00 2001 From: Benjamin Mako Hill Date: Fri, 31 Jul 2026 16:44:49 -0700 Subject: [PATCH] Phase 4: student views, opt-outs, and class schedule Student page with a standing summary, a class-comparison histogram (zero-dependency HTML/CSS) with a plain-language fewer/same/more sentence, opt-out management with withdrawal of future dates, and a call history that respects the per-course assessment-visibility setting and never shows pending or skipped calls. Opt-outs validate against a new per-course class-day schedule (range generator plus individual add/remove for holidays), since Canvas has no structured meeting-day data; courses without a schedule fall back to a free date picker. Dev mode seeds a Tue/Thu pattern. Co-Authored-By: Claude Fable 5 --- coldcall_lti/dev.py | 13 +- coldcall_lti/instructor.py | 65 ++++++++ coldcall_lti/models.py | 38 +++++ coldcall_lti/templates/base.html | 6 + coldcall_lti/templates/instructor_home.html | 1 + coldcall_lti/templates/schedule.html | 43 +++++ coldcall_lti/templates/student_home.html | 97 +++++++++++- coldcall_lti/views.py | 147 +++++++++++++++++- .../versions/37acdb847a25_class_days.py | 37 +++++ tests/test_schedule_ui.py | 78 ++++++++++ tests/test_student_ui.py | 118 ++++++++++++++ 11 files changed, 637 insertions(+), 6 deletions(-) create mode 100644 coldcall_lti/templates/schedule.html create mode 100644 migrations/versions/37acdb847a25_class_days.py create mode 100644 tests/test_schedule_ui.py create mode 100644 tests/test_student_ui.py diff --git a/coldcall_lti/dev.py b/coldcall_lti/dev.py index 3bfea54..8a30721 100644 --- a/coldcall_lti/dev.py +++ b/coldcall_lti/dev.py @@ -7,12 +7,14 @@ every other part of the app behaves identically. The fake roster goes through the same sync_roster path as real NRPS data. """ +import datetime + from flask import Blueprint, abort, redirect, render_template, session, url_for from sqlalchemy import select from .db import get_db from .launch import ROLE_INSTRUCTOR, ROLE_LEARNER -from .models import Course, Student +from .models import ClassDay, Course, Student, class_days from .roster import sync_roster bp = Blueprint("dev", __name__, url_prefix="/dev") @@ -64,6 +66,15 @@ def _ensure_dev_course(db): db.add(course) db.flush() sync_roster(db, course, DEV_MEMBERS) + + # A Tue/Thu meeting pattern for the next few weeks, so the opt-out + # picker and schedule pages have something to show. + if not class_days(db, course.id): + day = datetime.date.today() + for offset in range(28): + d = day + datetime.timedelta(days=offset) + if d.weekday() in (1, 3): + db.add(ClassDay(course_id=course.id, date=d)) db.commit() return course diff --git a/coldcall_lti/instructor.py b/coldcall_lti/instructor.py index d33d166..b167e1f 100644 --- a/coldcall_lti/instructor.py +++ b/coldcall_lti/instructor.py @@ -21,9 +21,11 @@ from .models import ( STATUS_MISSING, STATUS_SKIPPED, Call, + ClassDay, Enrollment, OptOut, Student, + class_days, students_present, ) from .views import current_course, require_instructor @@ -208,6 +210,69 @@ def day_save(date_str): return redirect(url_for(".day_edit", date_str=day.isoformat())) +@bp.get("/schedule") +@require_instructor +def schedule(): + db = get_db() + course = current_course() + return render_template( + "schedule.html", + course=course, + days=class_days(db, course.id), + today=datetime.date.today(), + weekdays=list(enumerate( + ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + )), + ) + + +@bp.post("/schedule/generate") +@require_instructor +def schedule_generate(): + db = get_db() + course = current_course() + start = _parse_date(request.form.get("start")) + end = _parse_date(request.form.get("end")) + chosen = {int(d) for d in request.form.getlist("weekday")} + if end < start or not chosen: + abort(400, "Need a valid date range and at least one weekday.") + if (end - start).days > 366: + abort(400, "Range longer than a year; check the dates.") + + existing = {d.date for d in class_days(db, course.id)} + day = start + while day <= end: + if day.weekday() in chosen and day not in existing: + db.add(ClassDay(course_id=course.id, date=day)) + day += datetime.timedelta(days=1) + db.commit() + return redirect(url_for(".schedule")) + + +@bp.post("/schedule/add") +@require_instructor +def schedule_add(): + db = get_db() + course = current_course() + day = _parse_date(request.form.get("date")) + if not any(d.date == day for d in class_days(db, course.id)): + db.add(ClassDay(course_id=course.id, date=day)) + db.commit() + return redirect(url_for(".schedule")) + + +@bp.post("/schedule//delete") +@require_instructor +def schedule_delete(day_id): + db = get_db() + day = db.get(ClassDay, day_id) + if day is None or day.course_id != current_course().id: + abort(404) + db.delete(day) + db.commit() + return redirect(url_for(".schedule")) + + @bp.get("/settings") @require_instructor def settings(): diff --git a/coldcall_lti/models.py b/coldcall_lti/models.py index 6e3315a..867992a 100644 --- a/coldcall_lti/models.py +++ b/coldcall_lti/models.py @@ -90,6 +90,19 @@ class Enrollment(Base): student: Mapped[Student] = relationship(back_populates="enrollments") +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 + falls back to a free date picker.""" + + __tablename__ = "class_days" + __table_args__ = (UniqueConstraint("course_id", "date"),) + + id: Mapped[int] = mapped_column(primary_key=True) + course_id: Mapped[int] = mapped_column(ForeignKey("courses.id")) + date: Mapped[datetime.date] = mapped_column(Date) + + class OptOut(Base): __tablename__ = "optouts" __table_args__ = (UniqueConstraint("course_id", "student_id", "date"),) @@ -132,6 +145,31 @@ def answered_call_counts(session, course_id): return dict(rows) +def class_days(session, course_id, start=None, end=None): + q = select(ClassDay).where(ClassDay.course_id == course_id) + if start is not None: + q = q.where(ClassDay.date >= start) + if end is not None: + q = q.where(ClassDay.date <= end) + return session.execute(q.order_by(ClassDay.date)).scalars().all() + + +def is_class_day(session, course_id, on_date): + """True if on_date is a listed class day, or if the course has no + class days defined at all (no schedule means no restriction).""" + if not session.execute( + select(ClassDay.id).where(ClassDay.course_id == course_id).limit(1) + ).first(): + return True + return bool( + session.execute( + select(ClassDay.id).where( + ClassDay.course_id == course_id, ClassDay.date == on_date + ) + ).first() + ) + + def students_present(session, course_id, on_date): """Active student-role enrollees minus those opted out for the date.""" opted_out = select(OptOut.student_id).where( diff --git a/coldcall_lti/templates/base.html b/coldcall_lti/templates/base.html index 1bc96e3..4d86176 100644 --- a/coldcall_lti/templates/base.html +++ b/coldcall_lti/templates/base.html @@ -16,6 +16,12 @@ 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 .notes { min-width: 18rem; } + .hist { display: flex; align-items: flex-end; gap: 0.3rem; height: 9rem; margin: 1rem 0 0.25rem; } + .hist .bin { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; height: 100%; width: 2.2rem; } + .hist .bar { width: 100%; background: #7a9cc6; min-height: 1px; } + .hist .bar.me { background: #d9822b; } + .hist .bar-n { font-size: 0.75rem; } + .hist .bar-x { font-size: 0.8rem; } @media print { .no-print { display: none; } body { margin: 0.5rem; max-width: none; } diff --git a/coldcall_lti/templates/instructor_home.html b/coldcall_lti/templates/instructor_home.html index 3ef2106..51a812a 100644 --- a/coldcall_lti/templates/instructor_home.html +++ b/coldcall_lti/templates/instructor_home.html @@ -10,6 +10,7 @@

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

diff --git a/coldcall_lti/templates/schedule.html b/coldcall_lti/templates/schedule.html new file mode 100644 index 0000000..ea9ed49 --- /dev/null +++ b/coldcall_lti/templates/schedule.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}Schedule — {{ course.title or "Cold Call" }}{% endblock %} +{% block body %} +

← back

+

Class schedule

+

When class days are listed here, the student opt-out + form only offers these dates. With no days listed, students can pick + any date.

+ +

Add a range

+
+ + + {% for i, name in weekdays %} + + {% endfor %} + +
+ +

Add a single day

+
+ + +
+ +{% if days %} +

Class days

+ + {% for d in days %} + + + + + {% endfor %} +
+ {{ d.date.strftime("%a %b %-d, %Y") }} +
+ +
+
+{% endif %} +{% endblock %} diff --git a/coldcall_lti/templates/student_home.html b/coldcall_lti/templates/student_home.html index 94f4fb0..8e81481 100644 --- a/coldcall_lti/templates/student_home.html +++ b/coldcall_lti/templates/student_home.html @@ -3,5 +3,100 @@ {% block body %}

{{ course.title or course.lti_context_id }}

Hi {{ user.name }}.

-

Your call history and the absence form will appear here.

+ +

Your standing

+

+ You have answered {{ answered }} + question{{ "s" if answered != 1 }}. + {% if missing %} + You were called but absent (without opting out) + {{ missing }} time{{ "s" if missing != 1 }}. + {% endif %} + You have opted out of {{ optouts|length }} + class{{ "es" if optouts|length != 1 }}. +

+ +

How you compare

+{% set d = distribution %} +{% set fewer = d.bins | selectattr("calls", "lt", d.my_count) | sum(attribute="students") %} +{% set more = d.bins | selectattr("calls", "gt", d.my_count) | sum(attribute="students") %} +{% set same = d.bins | selectattr("calls", "eq", d.my_count) | sum(attribute="students") - 1 %} +

+ {{ fewer }} classmate{{ "s have" if fewer != 1 else " has" }} answered + fewer questions than you, {{ same }} the same number, and + {{ more }} more. The class median is {{ d.median }}. +

+ +

Students by number of questions answered; your bar is + the highlighted one.

+ +

Opting out

+{% if optouts %} + + + {% for o in optouts %} + + + + + {% endfor %} +
Date
{{ o.date.isoformat() }} + {% if o.date >= today %} +
+ +
+ {% else %} + past + {% endif %} +
+{% else %} +

You have not opted out of any classes.

+{% endif %} + +
+ {% if upcoming_days %} + + {% else %} + + {% endif %} + +
+ +{% if my_calls %} +

Your calls

+ + + {% for c in my_calls %} + + + + + {% endfor %} +
DateOutcome
{{ c.session_date.isoformat() }} + {% if c.status == "missing" %} + missing + {% elif course.show_assessments and c.assessment %} + {{ c.assessment }} + {% else %} + answered + {% endif %} +
+{% endif %} {% endblock %} diff --git a/coldcall_lti/views.py b/coldcall_lti/views.py index 7292bed..78bc4e4 100644 --- a/coldcall_lti/views.py +++ b/coldcall_lti/views.py @@ -1,9 +1,23 @@ +import datetime import functools +import statistics -from flask import Blueprint, abort, render_template, session +from flask import Blueprint, abort, redirect, render_template, request, session, url_for +from sqlalchemy import select from .db import get_db -from .models import Course, Student +from .models import ( + STATUS_ANSWERED, + STATUS_MISSING, + Call, + Course, + Enrollment, + OptOut, + Student, + answered_call_counts, + class_days, + is_class_day, +) bp = Blueprint("views", __name__) @@ -46,9 +60,134 @@ def index(): return render_template("index.html", course=None, user=None) +def _class_call_distribution(db, course, my_student_id): + """Answered-call counts for every active student, as histogram bins + (count value -> number of students), the student's own count, and + the class median. Includes zeros for never-called students so the + comparison is honest.""" + active_ids = set( + db.execute( + select(Enrollment.student_id).where( + Enrollment.course_id == course.id, + Enrollment.role == "student", + Enrollment.active.is_(True), + ) + ).scalars() + ) + counts = answered_call_counts(db, course.id) + per_student = {sid: counts.get(sid, 0) for sid in active_ids} + if my_student_id not in per_student: + per_student[my_student_id] = counts.get(my_student_id, 0) + + values = list(per_student.values()) + bins = {} + for v in values: + bins[v] = bins.get(v, 0) + 1 + return { + "bins": [ + {"calls": k, "students": bins.get(k, 0)} + for k in range(0, max(values) + 1) + ], + "max_students": max(bins.values()), + "my_count": per_student[my_student_id], + "median": statistics.median(values), + } + + @bp.get("/me") @require_launch def student_home(): - return render_template( - "student_home.html", course=current_course(), user=current_user() + db = get_db() + course = current_course() + user = current_user() + today = datetime.date.today() + + my_calls = ( + db.execute( + select(Call) + .where( + Call.course_id == course.id, + Call.student_id == user.id, + Call.status.in_([STATUS_ANSWERED, STATUS_MISSING]), + ) + .order_by(Call.session_date, Call.id) + ) + .scalars() + .all() ) + optouts = ( + db.execute( + select(OptOut) + .where( + OptOut.course_id == course.id, OptOut.student_id == user.id + ) + .order_by(OptOut.date) + ) + .scalars() + .all() + ) + upcoming_days = [ + d.date + for d in class_days(db, course.id, start=today) + if d.date not in {o.date for o in optouts} + ] + + return render_template( + "student_home.html", + course=course, + user=user, + today=today, + my_calls=my_calls, + answered=sum(1 for c in my_calls if c.status == STATUS_ANSWERED), + missing=sum(1 for c in my_calls if c.status == STATUS_MISSING), + optouts=optouts, + upcoming_days=upcoming_days, + distribution=_class_call_distribution(db, course, user.id), + ) + + +@bp.post("/me/optout") +@require_launch +def optout_create(): + db = get_db() + course = current_course() + user = current_user() + + try: + day = datetime.date.fromisoformat(request.form.get("date", "")) + except ValueError: + abort(400, "Bad date; expected YYYY-MM-DD.") + if day < datetime.date.today(): + abort(400, "That date has already passed.") + if not is_class_day(db, course.id, day): + abort(400, "That is not a class day.") + + exists = db.execute( + select(OptOut).where( + OptOut.course_id == course.id, + OptOut.student_id == user.id, + OptOut.date == day, + ) + ).scalar_one_or_none() + if exists is None: + db.add(OptOut(course_id=course.id, student_id=user.id, date=day)) + db.commit() + return redirect(url_for(".student_home")) + + +@bp.post("/me/optout//delete") +@require_launch +def optout_delete(optout_id): + db = get_db() + optout = db.get(OptOut, optout_id) + if ( + optout is None + or optout.course_id != current_course().id + or optout.student_id != current_user().id + ): + abort(404) + if optout.date < datetime.date.today(): + abort(400, "Past opt-outs cannot be withdrawn.") + db.delete(optout) + db.commit() + return redirect(url_for(".student_home")) diff --git a/migrations/versions/37acdb847a25_class_days.py b/migrations/versions/37acdb847a25_class_days.py new file mode 100644 index 0000000..d2da45e --- /dev/null +++ b/migrations/versions/37acdb847a25_class_days.py @@ -0,0 +1,37 @@ +"""class days + +Revision ID: 37acdb847a25 +Revises: ad7e3f5ea2f3 +Create Date: 2026-07-31 16:40:25.479997 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '37acdb847a25' +down_revision: Union[str, None] = 'ad7e3f5ea2f3' +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.create_table('class_days', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('course_id', sa.Integer(), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('course_id', 'date') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('class_days') + # ### end Alembic commands ### diff --git a/tests/test_schedule_ui.py b/tests/test_schedule_ui.py new file mode 100644 index 0000000..eb59c85 --- /dev/null +++ b/tests/test_schedule_ui.py @@ -0,0 +1,78 @@ +import datetime +import re + +import pytest + + +@pytest.fixture +def instructor(dev_client): + dev_client.post("/dev/launch/dev-instructor") + return dev_client + + +def test_generate_range_creates_weekday_dates(instructor): + # A fixed two-week window well in the future, Mon/Wed. + resp = instructor.post( + "/instructor/schedule/generate", + data={ + "start": "2027-01-04", + "end": "2027-01-15", + "weekday": ["0", "2"], + }, + follow_redirects=True, + ) + page = resp.get_data(as_text=True) + for expected in ["Jan 4", "Jan 6", "Jan 11", "Jan 13"]: + assert expected in page + assert "Jan 5" not in page + + # Re-generating the same range adds nothing new. + resp = instructor.post( + "/instructor/schedule/generate", + data={"start": "2027-01-04", "end": "2027-01-15", "weekday": ["0"]}, + follow_redirects=True, + ) + assert resp.get_data(as_text=True).count("Jan 4") == 1 + + +def test_generate_rejects_bad_input(instructor): + resp = instructor.post( + "/instructor/schedule/generate", + data={"start": "2027-01-15", "end": "2027-01-04", "weekday": ["0"]}, + ) + assert resp.status_code == 400 + resp = instructor.post( + "/instructor/schedule/generate", + data={"start": "2027-01-04", "end": "2027-01-15"}, + ) + assert resp.status_code == 400 + + +def test_add_and_delete_single_day(instructor): + resp = instructor.post( + "/instructor/schedule/add", + data={"date": "2027-03-01"}, + follow_redirects=True, + ) + page = resp.get_data(as_text=True) + assert "Mar 1, 2027" in page + + # Find the delete form id adjacent to the date row. + rows = re.findall( + r"]*>\s*(\w{3} \w{3} \d+, \d{4})\s*.*?/instructor/schedule/(\d+)/delete", + page, + re.S, + ) + ids = {label: did for label, did in rows} + day_id = ids["Mon Mar 1, 2027"] + + resp = instructor.post( + f"/instructor/schedule/{day_id}/delete", follow_redirects=True + ) + assert "Mar 1, 2027" not in resp.get_data(as_text=True) + + +def test_schedule_requires_instructor(dev_client): + dev_client.post("/dev/launch/dev-instructor") # create course + dev_client.post("/dev/launch/dev-student-1") + assert dev_client.get("/instructor/schedule").status_code == 403 diff --git a/tests/test_student_ui.py b/tests/test_student_ui.py new file mode 100644 index 0000000..7ebd112 --- /dev/null +++ b/tests/test_student_ui.py @@ -0,0 +1,118 @@ +import datetime +import re + +import pytest + +TODAY = datetime.date.today() + + +@pytest.fixture +def student(dev_client): + # Instructor launch first so the dev course and roster exist. + dev_client.post("/dev/launch/dev-instructor") + dev_client.post("/dev/launch/dev-student-1") + return dev_client + + +def next_class_day_iso(page): + """First offered opt-out date from the student page's select.""" + match = re.search(r'