1
0

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:44:49 -07:00
parent 903209d3d5
commit 7845167ec5
11 changed files with 637 additions and 6 deletions

View File

@@ -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

View File

@@ -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/<int:day_id>/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():

View File

@@ -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(

View File

@@ -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; }

View File

@@ -10,6 +10,7 @@
<p>
<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.schedule') }}">Schedule</a> ·
<a href="{{ url_for('instructor.settings') }}">Settings</a>
</p>

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Schedule — {{ course.title or "Cold Call" }}{% endblock %}
{% block body %}
<p><a href="{{ url_for('instructor.home') }}">&larr; back</a></p>
<h1>Class schedule</h1>
<p class="muted">When class days are listed here, the student opt-out
form only offers these dates. With no days listed, students can pick
any date.</p>
<h2>Add a range</h2>
<form method="post" action="{{ url_for('instructor.schedule_generate') }}">
<label>From <input type="date" name="start" required></label>
<label>to <input type="date" name="end" required></label>
{% for i, name in weekdays %}
<label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label>
{% endfor %}
<button type="submit">Add class days</button>
</form>
<h2>Add a single day</h2>
<form method="post" action="{{ url_for('instructor.schedule_add') }}">
<label>Date <input type="date" name="date" required></label>
<button type="submit">Add</button>
</form>
{% if days %}
<h2>Class days</h2>
<table>
{% for d in days %}
<tr>
<td {% if d.date < today %}class="muted"{% endif %}>
{{ d.date.strftime("%a %b %-d, %Y") }}</td>
<td>
<form method="post"
action="{{ url_for('instructor.schedule_delete', day_id=d.id) }}">
<button type="submit">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}

View File

@@ -3,5 +3,100 @@
{% block body %}
<h1>{{ course.title or course.lti_context_id }}</h1>
<p>Hi {{ user.name }}.</p>
<p class="muted">Your call history and the absence form will appear here.</p>
<h2>Your standing</h2>
<p>
You have answered <strong>{{ answered }}</strong>
question{{ "s" if answered != 1 }}.
{% if missing %}
You were called but absent (without opting out)
<strong>{{ missing }}</strong> time{{ "s" if missing != 1 }}.
{% endif %}
You have opted out of <strong>{{ optouts|length }}</strong>
class{{ "es" if optouts|length != 1 }}.
</p>
<h2>How you compare</h2>
{% 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 %}
<p>
{{ 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 }}.
</p>
<div class="hist" role="img"
aria-label="Histogram of questions answered across the class; your bar is highlighted.">
{% for bin in d.bins %}
<div class="bin">
<div class="bar-n muted">{{ bin.students or "" }}</div>
<div class="bar {{ 'me' if bin.calls == d.my_count }}"
style="height: {{ (bin.students / d.max_students * 100) | round }}%"></div>
<div class="bar-x">{{ bin.calls }}</div>
</div>
{% endfor %}
</div>
<p class="muted">Students by number of questions answered; your bar is
the highlighted one.</p>
<h2>Opting out</h2>
{% if optouts %}
<table>
<tr><th>Date</th><th></th></tr>
{% for o in optouts %}
<tr>
<td>{{ o.date.isoformat() }}</td>
<td>
{% if o.date >= today %}
<form method="post"
action="{{ url_for('views.optout_delete', optout_id=o.id) }}">
<button type="submit">Withdraw</button>
</form>
{% else %}
<span class="muted">past</span>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">You have not opted out of any classes.</p>
{% endif %}
<form method="post" action="{{ url_for('views.optout_create') }}">
{% if upcoming_days %}
<label>Class date:
<select name="date">
{% for day in upcoming_days %}
<option value="{{ day.isoformat() }}">{{ day.strftime("%a %b %-d, %Y") }}</option>
{% endfor %}
</select>
</label>
{% else %}
<label>Date: <input type="date" name="date" min="{{ today.isoformat() }}"></label>
{% endif %}
<button type="submit">Opt out</button>
</form>
{% if my_calls %}
<h2>Your calls</h2>
<table>
<tr><th>Date</th><th>Outcome</th></tr>
{% for c in my_calls %}
<tr>
<td>{{ c.session_date.isoformat() }}</td>
<td>
{% if c.status == "missing" %}
missing
{% elif course.show_assessments and c.assessment %}
{{ c.assessment }}
{% else %}
answered
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock %}

View File

@@ -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/<int:optout_id>/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"))

View File

@@ -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 ###

78
tests/test_schedule_ui.py Normal file
View File

@@ -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"<td[^>]*>\s*(\w{3} \w{3} \d+, \d{4})\s*</td>.*?/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

118
tests/test_student_ui.py Normal file
View File

@@ -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'<option value="(\d{4}-\d{2}-\d{2})"', page)
return match.group(1)
def test_student_home_shows_standing_and_histogram(student):
page = student.get("/me").get_data(as_text=True)
assert "answered <strong>0</strong>" in page
assert "class median is 0" in page
assert 'class="hist"' in page
assert "classmate" in page
def test_optout_roundtrip(student):
page = student.get("/me").get_data(as_text=True)
day = next_class_day_iso(page)
resp = student.post("/me/optout", data={"date": day}, follow_redirects=True)
page = resp.get_data(as_text=True)
assert f"<td>{day}</td>" in page
assert "Withdraw" in page
# Duplicate submissions are ignored, not errors.
student.post("/me/optout", data={"date": day})
page = student.get("/me").get_data(as_text=True)
assert page.count(f"<td>{day}</td>") == 1
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
resp = student.post(f"/me/optout/{optout_id}/delete", follow_redirects=True)
assert f"<td>{day}</td>" not in resp.get_data(as_text=True)
def test_optout_rejects_non_class_day(student):
# Dev course meets Tue/Thu; find an upcoming Monday.
day = TODAY + datetime.timedelta(days=1)
while day.weekday() != 0:
day += datetime.timedelta(days=1)
resp = student.post("/me/optout", data={"date": day.isoformat()})
assert resp.status_code == 400
def test_optout_rejects_past_date(student):
resp = student.post(
"/me/optout",
data={"date": (TODAY - datetime.timedelta(days=7)).isoformat()},
)
assert resp.status_code == 400
def test_optout_affects_instructor_present_count(student):
page = student.get("/me").get_data(as_text=True)
day = next_class_day_iso(page)
student.post("/me/optout", data={"date": day})
student.post("/dev/launch/dev-instructor")
page = student.get(f"/instructor/live?date={day}").get_data(as_text=True)
assert "7 of 8 students available" in page
assert "(1 opted out)" in page
def test_students_cannot_touch_others_optouts(student):
page = student.get("/me").get_data(as_text=True)
day = next_class_day_iso(page)
student.post("/me/optout", data={"date": day})
page = student.get("/me").get_data(as_text=True)
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
student.post("/dev/launch/dev-student-2")
assert student.post(f"/me/optout/{optout_id}/delete").status_code == 404
def test_assessment_visibility_setting(student):
# Record an answered call for dev-student-1 as the instructor.
student.post("/dev/launch/dev-instructor")
day = TODAY.isoformat()
# Draw live calls until dev-student-1 comes up, resolving each.
for _ in range(200):
student.post("/instructor/live/next", data={"date": day})
page = student.get(f"/instructor/live?date={day}").get_data(as_text=True)
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"}
)
if target:
break
student.post("/dev/launch/dev-student-1")
page = student.get("/me").get_data(as_text=True)
assert "POOR" in page
# Turn visibility off; the outcome shows as just "answered".
student.post("/dev/launch/dev-instructor")
student.post(
"/instructor/settings",
data={"selection_mode": "weighted", "weight_factor": "2"},
)
student.post("/dev/launch/dev-student-1")
page = student.get("/me").get_data(as_text=True)
assert "POOR" not in page
assert "answered" in page