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:
@@ -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.
|
through the same sync_roster path as real NRPS data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
from flask import Blueprint, abort, redirect, render_template, session, url_for
|
from flask import Blueprint, abort, redirect, render_template, session, url_for
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .launch import ROLE_INSTRUCTOR, ROLE_LEARNER
|
from .launch import ROLE_INSTRUCTOR, ROLE_LEARNER
|
||||||
from .models import Course, Student
|
from .models import ClassDay, Course, Student, class_days
|
||||||
from .roster import sync_roster
|
from .roster import sync_roster
|
||||||
|
|
||||||
bp = Blueprint("dev", __name__, url_prefix="/dev")
|
bp = Blueprint("dev", __name__, url_prefix="/dev")
|
||||||
@@ -64,6 +66,15 @@ def _ensure_dev_course(db):
|
|||||||
db.add(course)
|
db.add(course)
|
||||||
db.flush()
|
db.flush()
|
||||||
sync_roster(db, course, DEV_MEMBERS)
|
sync_roster(db, course, DEV_MEMBERS)
|
||||||
|
|
||||||
|
# A Tue/Thu meeting pattern for the next few weeks, so the opt-out
|
||||||
|
# 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()
|
db.commit()
|
||||||
return course
|
return course
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ from .models import (
|
|||||||
STATUS_MISSING,
|
STATUS_MISSING,
|
||||||
STATUS_SKIPPED,
|
STATUS_SKIPPED,
|
||||||
Call,
|
Call,
|
||||||
|
ClassDay,
|
||||||
Enrollment,
|
Enrollment,
|
||||||
OptOut,
|
OptOut,
|
||||||
Student,
|
Student,
|
||||||
|
class_days,
|
||||||
students_present,
|
students_present,
|
||||||
)
|
)
|
||||||
from .views import current_course, require_instructor
|
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()))
|
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")
|
@bp.get("/settings")
|
||||||
@require_instructor
|
@require_instructor
|
||||||
def settings():
|
def settings():
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ class Enrollment(Base):
|
|||||||
student: Mapped[Student] = relationship(back_populates="enrollments")
|
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):
|
class OptOut(Base):
|
||||||
__tablename__ = "optouts"
|
__tablename__ = "optouts"
|
||||||
__table_args__ = (UniqueConstraint("course_id", "student_id", "date"),)
|
__table_args__ = (UniqueConstraint("course_id", "student_id", "date"),)
|
||||||
@@ -132,6 +145,31 @@ def answered_call_counts(session, course_id):
|
|||||||
return dict(rows)
|
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):
|
def students_present(session, course_id, on_date):
|
||||||
"""Active student-role enrollees minus those opted out for the date."""
|
"""Active student-role enrollees minus those opted out for the date."""
|
||||||
opted_out = select(OptOut.student_id).where(
|
opted_out = select(OptOut.student_id).where(
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
button.big { font-size: 1.5rem; padding: 1rem 2rem; }
|
button.big { font-size: 1.5rem; padding: 1rem 2rem; }
|
||||||
.printlist td { border-bottom: 1px solid #ccc; padding: 0.5rem 0.75rem 0.5rem 0; }
|
.printlist td { border-bottom: 1px solid #ccc; padding: 0.5rem 0.75rem 0.5rem 0; }
|
||||||
.printlist .notes { min-width: 18rem; }
|
.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 {
|
@media print {
|
||||||
.no-print { display: none; }
|
.no-print { display: none; }
|
||||||
body { margin: 0.5rem; max-width: none; }
|
body { margin: 0.5rem; max-width: none; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<p>
|
<p>
|
||||||
<a href="{{ url_for('instructor.live') }}">Live cold call</a> ·
|
<a href="{{ url_for('instructor.live') }}">Live cold call</a> ·
|
||||||
<a href="{{ url_for('instructor.day_edit', date_str=today.isoformat()) }}">Today's calls</a> ·
|
<a href="{{ url_for('instructor.day_edit', date_str=today.isoformat()) }}">Today's calls</a> ·
|
||||||
|
<a href="{{ url_for('instructor.schedule') }}">Schedule</a> ·
|
||||||
<a href="{{ url_for('instructor.settings') }}">Settings</a>
|
<a href="{{ url_for('instructor.settings') }}">Settings</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
43
coldcall_lti/templates/schedule.html
Normal file
43
coldcall_lti/templates/schedule.html
Normal 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') }}">← 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 %}
|
||||||
@@ -3,5 +3,100 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<h1>{{ course.title or course.lti_context_id }}</h1>
|
<h1>{{ course.title or course.lti_context_id }}</h1>
|
||||||
<p>Hi {{ user.name }}.</p>
|
<p>Hi {{ user.name }}.</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
|
import datetime
|
||||||
import functools
|
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 .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__)
|
bp = Blueprint("views", __name__)
|
||||||
|
|
||||||
@@ -46,9 +60,134 @@ def index():
|
|||||||
return render_template("index.html", course=None, user=None)
|
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")
|
@bp.get("/me")
|
||||||
@require_launch
|
@require_launch
|
||||||
def student_home():
|
def student_home():
|
||||||
return render_template(
|
db = get_db()
|
||||||
"student_home.html", course=current_course(), user=current_user()
|
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"))
|
||||||
|
|||||||
37
migrations/versions/37acdb847a25_class_days.py
Normal file
37
migrations/versions/37acdb847a25_class_days.py
Normal 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
78
tests/test_schedule_ui.py
Normal 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
118
tests/test_student_ui.py
Normal 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
|
||||||
Reference in New Issue
Block a user