diff --git a/coldcall_lti/__init__.py b/coldcall_lti/__init__.py index 6cbbc3b..ad3098a 100644 --- a/coldcall_lti/__init__.py +++ b/coldcall_lti/__init__.py @@ -23,10 +23,11 @@ def create_app(config=Config): app.config["SESSION_COOKIE_SAMESITE"] = "None" app.config["SESSION_COOKIE_SECURE"] = True - from . import lti, views + from . import instructor, lti, views app.register_blueprint(lti.bp) app.register_blueprint(views.bp) + app.register_blueprint(instructor.bp) if app.config["DEV_MODE"]: from . import dev diff --git a/coldcall_lti/calls.py b/coldcall_lti/calls.py new file mode 100644 index 0000000..815f24a --- /dev/null +++ b/coldcall_lti/calls.py @@ -0,0 +1,128 @@ +"""Database-aware call operations: picking the next student in live +mode, generating a day's call list, and managing pending calls. + +Selection math lives in selection.py; this module feeds it from the +database and honors the course's selection mode and weight factor. +""" + +import random + +from sqlalchemy import select + +from . import selection +from .models import ( + SELECTION_CYCLE, + STATUS_PENDING, + STATUS_SKIPPED, + Call, + answered_call_counts, + students_present, +) + + +def calls_for_day(db, course_id, day): + return ( + db.execute( + select(Call) + .where(Call.course_id == course_id, Call.session_date == day) + .order_by(Call.created_at, Call.id) + ) + .scalars() + .all() + ) + + +def unresolved_call(db, course_id, day): + """The oldest pending call for the day, if any. Live mode requires + resolving it before drawing the next student.""" + return db.execute( + select(Call) + .where( + Call.course_id == course_id, + Call.session_date == day, + Call.status == STATUS_PENDING, + ) + .order_by(Call.created_at, Call.id) + ).scalars().first() + + +def pick_next_student(db, course, day, rng=random): + """Choose who to call next, honoring opt-outs and the course's + selection mode. Returns None if nobody is available.""" + present = students_present(db, course.id, day) + if not present: + return None + + if course.selection_mode == SELECTION_CYCLE: + # Everyone once per day, in random order: draw from those not + # yet called today, starting over once the roster is exhausted. + called_today = { + c.student_id + for c in calls_for_day(db, course.id, day) + if c.status != STATUS_SKIPPED + } + pool = [s for s in present if s.id not in called_today] or present + return rng.choice(pool) + + by_id = {s.id: s for s in present} + counts = answered_call_counts(db, course.id) + picked = selection.select_student( + list(by_id), counts, course.weight_factor, rng + ) + return by_id[picked] + + +def create_live_call(db, course, day, rng=random): + student = pick_next_student(db, course, day, rng) + if student is None: + return None + call = Call( + course_id=course.id, + student_id=student.id, + session_date=day, + status=STATUS_PENDING, + ) + db.add(call) + db.flush() + return call + + +def generate_day_list(db, course, day, n=None, rng=random): + """Create pending calls for a printable list. Cycle mode shuffles + the whole present roster; weighted mode draws n picks with + within-list downweighting, like the old manual script.""" + present = students_present(db, course.id, day) + if not present: + return [] + + ids = [s.id for s in present] + if course.selection_mode == SELECTION_CYCLE: + ordered = selection.generate_cycle_list(ids, rng) + else: + counts = answered_call_counts(db, course.id) + ordered = selection.generate_call_list( + ids, counts, n or len(ids), course.weight_factor, rng + ) + + calls = [ + Call( + course_id=course.id, + student_id=student_id, + session_date=day, + status=STATUS_PENDING, + ) + for student_id in ordered + ] + db.add_all(calls) + db.flush() + return calls + + +def clear_pending(db, course_id, day): + removed = 0 + for call in calls_for_day(db, course_id, day): + if call.status == STATUS_PENDING: + db.delete(call) + removed += 1 + db.flush() + return removed diff --git a/coldcall_lti/dev.py b/coldcall_lti/dev.py index 179cddd..3bfea54 100644 --- a/coldcall_lti/dev.py +++ b/coldcall_lti/dev.py @@ -93,5 +93,5 @@ def fake_launch(user_id): session["is_instructor"] = is_instructor if is_instructor: - return redirect(url_for("views.instructor_home")) + return redirect(url_for("instructor.home")) return redirect(url_for("views.student_home")) diff --git a/coldcall_lti/instructor.py b/coldcall_lti/instructor.py new file mode 100644 index 0000000..d33d166 --- /dev/null +++ b/coldcall_lti/instructor.py @@ -0,0 +1,235 @@ +import datetime + +from flask import ( + Blueprint, + abort, + redirect, + render_template, + request, + url_for, +) +from sqlalchemy import case, func, select + +from . import calls +from .db import get_db +from .models import ( + ASSESSMENTS, + CALL_STATUSES, + SELECTION_CYCLE, + SELECTION_WEIGHTED, + STATUS_ANSWERED, + STATUS_MISSING, + STATUS_SKIPPED, + Call, + Enrollment, + OptOut, + Student, + students_present, +) +from .views import current_course, require_instructor + +bp = Blueprint("instructor", __name__, url_prefix="/instructor") + + +def _parse_date(value): + if not value: + return datetime.date.today() + try: + return datetime.date.fromisoformat(value) + except ValueError: + abort(400, "Bad date; expected YYYY-MM-DD.") + + +def _get_course_call(db, call_id): + call = db.get(Call, call_id) + if call is None or call.course_id != current_course().id: + abort(404) + return call + + +def _day_context(db, course, day): + roster_count = db.execute( + select(func.count()) + .select_from(Enrollment) + .where( + Enrollment.course_id == course.id, + Enrollment.role == "student", + Enrollment.active.is_(True), + ) + ).scalar_one() + present = students_present(db, course.id, day) + return { + "course": course, + "day": day, + "roster_count": roster_count, + "present_count": len(present), + "optout_count": roster_count - len(present), + } + + +@bp.get("/") +@require_instructor +def home(): + db = get_db() + course = current_course() + today = datetime.date.today() + + recent_days = db.execute( + select( + Call.session_date, + func.count(), + func.sum(case((Call.status == STATUS_ANSWERED, 1), else_=0)), + ) + .where(Call.course_id == course.id) + .group_by(Call.session_date) + .order_by(Call.session_date.desc()) + .limit(10) + ).all() + + context = _day_context(db, course, today) + context.update(recent_days=recent_days, today=today) + return render_template("instructor_home.html", **context) + + +@bp.get("/live") +@require_instructor +def live(): + db = get_db() + course = current_course() + day = _parse_date(request.args.get("date")) + + call = calls.unresolved_call(db, course.id, day) + day_calls = [ + c for c in calls.calls_for_day(db, course.id, day) + if c.status != calls.STATUS_PENDING + ] + context = _day_context(db, course, day) + context.update(call=call, day_calls=day_calls, assessments=ASSESSMENTS) + return render_template("live.html", **context) + + +@bp.post("/live/next") +@require_instructor +def live_next(): + db = get_db() + course = current_course() + day = _parse_date(request.form.get("date")) + + if calls.unresolved_call(db, course.id, day) is None: + calls.create_live_call(db, course, day) + db.commit() + return redirect(url_for(".live", date=day.isoformat())) + + +@bp.post("/call//outcome") +@require_instructor +def call_outcome(call_id): + db = get_db() + call = _get_course_call(db, call_id) + + action = request.form.get("action", "") + if action in ASSESSMENTS: + call.status = STATUS_ANSWERED + call.assessment = action + elif action == "skip": + call.status = STATUS_SKIPPED + call.assessment = None + elif action == "missing": + call.status = STATUS_MISSING + call.assessment = None + else: + abort(400, "Unknown outcome.") + db.commit() + return redirect( + url_for(".live", date=call.session_date.isoformat()) + ) + + +@bp.post("/day//generate") +@require_instructor +def generate(date_str): + db = get_db() + course = current_course() + day = _parse_date(date_str) + + n = request.form.get("n", type=int) + calls.clear_pending(db, course.id, day) + calls.generate_day_list(db, course, day, n=n) + db.commit() + return redirect(url_for(".day_print", date_str=day.isoformat())) + + +@bp.get("/day//print") +@require_instructor +def day_print(date_str): + db = get_db() + course = current_course() + day = _parse_date(date_str) + day_calls = calls.calls_for_day(db, course.id, day) + return render_template( + "print_list.html", course=course, day=day, day_calls=day_calls + ) + + +@bp.get("/day/") +@require_instructor +def day_edit(date_str): + db = get_db() + course = current_course() + day = _parse_date(date_str) + context = _day_context(db, course, day) + context.update( + day_calls=calls.calls_for_day(db, course.id, day), + assessments=ASSESSMENTS, + statuses=CALL_STATUSES, + ) + return render_template("day_edit.html", **context) + + +@bp.post("/day/") +@require_instructor +def day_save(date_str): + db = get_db() + course = current_course() + day = _parse_date(date_str) + + for call in calls.calls_for_day(db, course.id, day): + if request.form.get(f"delete-{call.id}"): + db.delete(call) + continue + 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 + 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())) + + +@bp.get("/settings") +@require_instructor +def settings(): + return render_template( + "settings.html", + course=current_course(), + modes=(SELECTION_WEIGHTED, SELECTION_CYCLE), + ) + + +@bp.post("/settings") +@require_instructor +def settings_save(): + db = get_db() + course = current_course() + + mode = request.form.get("selection_mode") + if mode in (SELECTION_WEIGHTED, SELECTION_CYCLE): + course.selection_mode = mode + weight = request.form.get("weight_factor", type=float) + if weight and weight >= 1.0: + course.weight_factor = weight + course.show_assessments = bool(request.form.get("show_assessments")) + db.commit() + return redirect(url_for(".settings")) diff --git a/coldcall_lti/lti.py b/coldcall_lti/lti.py index 1b8640b..436e9b5 100644 --- a/coldcall_lti/lti.py +++ b/coldcall_lti/lti.py @@ -62,7 +62,7 @@ def launch(): session["is_instructor"] = info.is_instructor if info.is_instructor: - return redirect(url_for("views.instructor_home")) + return redirect(url_for("instructor.home")) return redirect(url_for("views.student_home")) diff --git a/coldcall_lti/templates/base.html b/coldcall_lti/templates/base.html index 00cdcfe..1bc96e3 100644 --- a/coldcall_lti/templates/base.html +++ b/coldcall_lti/templates/base.html @@ -10,6 +10,16 @@ table { border-collapse: collapse; } th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; } .muted { color: #666; } + .callcard { margin: 1.5rem 0; } + .callname { font-size: 2.25rem; font-weight: bold; margin: 0.5rem 0 0; } + 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; } + .printlist .notes { min-width: 18rem; } + @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 new file mode 100644 index 0000000..56afc64 --- /dev/null +++ b/coldcall_lti/templates/day_edit.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Calls {{ day.isoformat() }}{% endblock %} +{% block body %} +

← back · + printable list

+

Calls for {{ day.isoformat() }}

+ +{% if day_calls %} +
+ + + + + + {% for c in day_calls %} + + + + + + + + + {% endfor %} +
#StudentStatusAssessmentNoteDelete
{{ loop.index }}{{ c.student.name }} + + + +
+

+
+{% else %} +

No calls recorded for this day.

+{% endif %} +{% endblock %} diff --git a/coldcall_lti/templates/instructor_home.html b/coldcall_lti/templates/instructor_home.html index 0b1394a..3ef2106 100644 --- a/coldcall_lti/templates/instructor_home.html +++ b/coldcall_lti/templates/instructor_home.html @@ -2,14 +2,42 @@ {% block title %}{{ course.title or "Cold Call" }}{% endblock %} {% block body %}

{{ course.title or course.lti_context_id }}

-

{{ roster|length }} active students on the roster.

+

+ {{ roster_count }} active students on the roster; + {{ present_count }} available today ({{ optout_count }} opted out). +

+ +

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

+ +

Printable list

+
+ + +
+

Generating replaces any unresolved calls already + pending for the day; recorded outcomes are kept.

+ +{% if recent_days %} +

Recent days

- - {% for student in roster %} + + {% for day, total, answered in recent_days %} - - + + + + {% endfor %}
NameEmail
DateCallsAnswered
{{ student.name }}{{ student.email or "" }}{{ day.isoformat() }}{{ total }}{{ answered or 0 }} + edit · + print +
+{% endif %} {% endblock %} diff --git a/coldcall_lti/templates/live.html b/coldcall_lti/templates/live.html new file mode 100644 index 0000000..bf41de5 --- /dev/null +++ b/coldcall_lti/templates/live.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Live — {{ course.title or "Cold Call" }}{% endblock %} +{% block body %} +

← back

+

Live cold call — {{ day.isoformat() }}

+

+ {{ present_count }} of {{ roster_count }} students available + ({{ optout_count }} opted out). +

+ +{% if call %} +
+ {% if call.student.avatar_url %} + + {% endif %} +

{{ call.student.name }}

+ {% if call.student.sortable_name %} +

{{ call.student.sortable_name }}

+ {% endif %} +
+
+ {% for a in assessments %} + + {% endfor %} + + +
+{% elif present_count > 0 %} +
+ + +
+{% else %} +

No students available to call.

+{% endif %} + +{% if day_calls %} +

Calls so far today

+ + + {% for c in day_calls %} + + + + + + {% endfor %} +
#StudentOutcome
{{ loop.index }}{{ c.student.name }}{{ c.assessment or c.status }}
+{% endif %} +{% endblock %} diff --git a/coldcall_lti/templates/print_list.html b/coldcall_lti/templates/print_list.html new file mode 100644 index 0000000..1d15734 --- /dev/null +++ b/coldcall_lti/templates/print_list.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}Call list {{ day.isoformat() }}{% endblock %} +{% block body %} +

+ ← back · + edit outcomes +

+

{{ course.title or course.lti_context_id }} — {{ day.isoformat() }}

+{% if day_calls %} + + + {% for c in day_calls %} + + + + + + + {% endfor %} +
#StudentNotes
{{ loop.index }} + {% if c.student.avatar_url %} + + {% endif %} + + {{ c.student.name }} + {% if c.student.sortable_name and c.student.sortable_name != c.student.name %} + ({{ c.student.sortable_name }}) + {% endif %} +
+{% else %} +

No calls generated for this day yet.

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

← back

+

Course settings

+
+

+ + weighted: students already called are less + likely to be called again; cycle: everyone once per class in + random order. +

+

+ + Each answered call divides a student's + selection weight by this. 1 means uniform selection; 2 is the + long-standing default. +

+

+ +

+

+
+{% endblock %} diff --git a/coldcall_lti/views.py b/coldcall_lti/views.py index 80bafc2..7292bed 100644 --- a/coldcall_lti/views.py +++ b/coldcall_lti/views.py @@ -1,10 +1,9 @@ import functools from flask import Blueprint, abort, render_template, session -from sqlalchemy import select from .db import get_db -from .models import Course, Enrollment, Student +from .models import Course, Student bp = Blueprint("views", __name__) @@ -47,26 +46,6 @@ def index(): return render_template("index.html", course=None, user=None) -@bp.get("/instructor") -@require_instructor -def instructor_home(): - db = get_db() - course = current_course() - roster = db.execute( - select(Student) - .join(Enrollment, 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) - ).scalars().all() - return render_template( - "instructor_home.html", course=course, roster=roster - ) - - @bp.get("/me") @require_launch def student_home(): diff --git a/tests/conftest.py b/tests/conftest.py index 72554e9..79d1ac3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from coldcall_lti import create_app +from coldcall_lti.config import Config from coldcall_lti.db import Base @@ -13,3 +15,20 @@ def db_session(): yield session session.close() engine.dispose() + + +def make_app(tmp_path, dev_mode=True): + class TestConfig(Config): + TESTING = True + DATABASE_URL = f"sqlite:///{tmp_path}/test.sqlite3" + DEV_MODE = dev_mode + SECRET_KEY = "test" + + app = create_app(TestConfig) + Base.metadata.create_all(app.extensions["db_engine"]) + return app + + +@pytest.fixture +def dev_client(tmp_path): + return make_app(tmp_path, dev_mode=True).test_client() diff --git a/tests/test_app.py b/tests/test_app.py index f58cdf4..f0fa2f6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,25 +1,4 @@ -import pytest - -from coldcall_lti import create_app -from coldcall_lti.config import Config -from coldcall_lti.db import Base - - -def make_app(tmp_path, dev_mode): - class TestConfig(Config): - TESTING = True - DATABASE_URL = f"sqlite:///{tmp_path}/test.sqlite3" - DEV_MODE = dev_mode - SECRET_KEY = "test" - - app = create_app(TestConfig) - Base.metadata.create_all(app.extensions["db_engine"]) - return app - - -@pytest.fixture -def dev_client(tmp_path): - return make_app(tmp_path, dev_mode=True).test_client() +from conftest import make_app def test_healthz(tmp_path): @@ -33,7 +12,7 @@ def test_dev_routes_absent_outside_dev_mode(tmp_path): def test_views_require_launch(dev_client): - assert dev_client.get("/instructor").status_code == 403 + assert dev_client.get("/instructor/").status_code == 403 assert dev_client.get("/me").status_code == 403 @@ -45,7 +24,6 @@ def test_dev_instructor_launch_shows_roster(dev_client): assert resp.status_code == 200 page = resp.get_data(as_text=True) assert "8 active students" in page - assert "Ada Lovelace" in page def test_dev_student_launch(dev_client): @@ -54,5 +32,6 @@ def test_dev_student_launch(dev_client): ) page = resp.get_data(as_text=True) assert "Ada Lovelace" in page - # A student cannot reach the instructor view. - assert dev_client.get("/instructor").status_code == 403 + # A student cannot reach the instructor views. + assert dev_client.get("/instructor/").status_code == 403 + assert dev_client.get("/instructor/live").status_code == 403 diff --git a/tests/test_calls.py b/tests/test_calls.py new file mode 100644 index 0000000..e0ad64b --- /dev/null +++ b/tests/test_calls.py @@ -0,0 +1,131 @@ +import datetime +import random +from collections import Counter + +from coldcall_lti import calls, models + +DAY = datetime.date(2026, 10, 1) + + +def make_course(db, n=4, mode=models.SELECTION_WEIGHTED): + course = models.Course(lti_context_id="ctx-1", selection_mode=mode) + db.add(course) + students = [] + for i in range(n): + 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 test_pick_next_honors_optouts(db_session): + course, students = make_course(db_session, n=2) + db_session.add( + models.OptOut( + course_id=course.id, student_id=students[0].id, date=DAY + ) + ) + db_session.flush() + rng = random.Random(1) + for _ in range(20): + assert calls.pick_next_student(db_session, course, DAY, rng).id == students[1].id + + +def test_pick_next_weighted_downweights(db_session): + course, students = make_course(db_session) + heavy = students[0] + for _ in range(4): + db_session.add( + models.Call( + course_id=course.id, + student_id=heavy.id, + session_date=DAY - datetime.timedelta(days=7), + status=models.STATUS_ANSWERED, + ) + ) + db_session.flush() + + rng = random.Random(42) + picks = Counter( + calls.pick_next_student(db_session, course, DAY, rng).id + for _ in range(400) + ) + assert picks[heavy.id] < min(picks[s.id] for s in students[1:]) / 2 + + +def test_cycle_mode_calls_everyone_before_repeating(db_session): + course, students = make_course(db_session, mode=models.SELECTION_CYCLE) + rng = random.Random(7) + called = [] + for _ in range(len(students)): + call = calls.create_live_call(db_session, course, DAY, rng) + call.status = models.STATUS_ANSWERED + db_session.flush() + called.append(call.student_id) + assert sorted(called) == sorted(s.id for s in students) + + # The next draw starts a fresh pass rather than failing. + assert calls.create_live_call(db_session, course, DAY, rng) is not None + + +def test_cycle_mode_recalls_skipped_students(db_session): + course, students = make_course(db_session, n=2, mode=models.SELECTION_CYCLE) + rng = random.Random(3) + call = calls.create_live_call(db_session, course, DAY, rng) + call.status = models.STATUS_SKIPPED + db_session.flush() + # A skipped call doesn't count as having been called this pass. + pool_ids = { + calls.pick_next_student(db_session, course, DAY, rng).id + for _ in range(20) + } + assert call.student_id in pool_ids + + +def test_generate_day_list_cycle_covers_roster(db_session): + course, students = make_course(db_session, mode=models.SELECTION_CYCLE) + generated = calls.generate_day_list(db_session, course, DAY, rng=random.Random(1)) + assert sorted(c.student_id for c in generated) == sorted(s.id for s in students) + assert all(c.status == models.STATUS_PENDING for c in generated) + + +def test_generate_day_list_weighted_length_and_optouts(db_session): + course, students = make_course(db_session) + db_session.add( + models.OptOut( + course_id=course.id, student_id=students[0].id, date=DAY + ) + ) + db_session.flush() + generated = calls.generate_day_list( + db_session, course, DAY, n=10, rng=random.Random(1) + ) + assert len(generated) == 10 + assert students[0].id not in {c.student_id for c in generated} + + +def test_clear_pending_keeps_resolved(db_session): + course, students = make_course(db_session) + generated = calls.generate_day_list( + db_session, course, DAY, n=5, rng=random.Random(1) + ) + generated[0].status = models.STATUS_ANSWERED + db_session.flush() + + removed = calls.clear_pending(db_session, course.id, DAY) + assert removed == 4 + remaining = calls.calls_for_day(db_session, course.id, DAY) + assert len(remaining) == 1 + assert remaining[0].status == models.STATUS_ANSWERED + + +def test_unresolved_call_is_oldest_pending(db_session): + course, students = make_course(db_session) + generated = calls.generate_day_list( + db_session, course, DAY, n=3, rng=random.Random(1) + ) + assert calls.unresolved_call(db_session, course.id, DAY).id == generated[0].id diff --git a/tests/test_instructor_ui.py b/tests/test_instructor_ui.py new file mode 100644 index 0000000..02e8a04 --- /dev/null +++ b/tests/test_instructor_ui.py @@ -0,0 +1,121 @@ +import datetime +import re + +import pytest + +TODAY = datetime.date.today().isoformat() + + +@pytest.fixture +def instructor(dev_client): + dev_client.post("/dev/launch/dev-instructor") + return dev_client + + +def test_home_page(instructor): + page = instructor.get("/instructor/").get_data(as_text=True) + assert "8 active students" in page + assert "Live cold call" in page + + +def test_live_flow_records_outcome(instructor): + resp = instructor.post( + "/instructor/live/next", data={"date": TODAY}, follow_redirects=True + ) + page = resp.get_data(as_text=True) + match = re.search(r'/instructor/call/(\d+)/outcome', page) + assert match, "live page should show outcome buttons for the call" + assert "GOOD" in page + + call_id = match.group(1) + resp = instructor.post( + f"/instructor/call/{call_id}/outcome", + data={"action": "GOOD"}, + follow_redirects=True, + ) + page = resp.get_data(as_text=True) + assert "Calls so far today" in page + assert "GOOD" in page + # The call is resolved, so the next-student button is back. + assert "Call next student" in page + + +def test_live_requires_resolution_before_next(instructor): + instructor.post("/instructor/live/next", data={"date": TODAY}) + page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True) + first_call = re.search(r'/instructor/call/(\d+)/outcome', page).group(1) + + # Pressing next again does not create a second pending call. + instructor.post("/instructor/live/next", data={"date": TODAY}) + page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True) + assert re.findall(r'/instructor/call/(\d+)/outcome', page) == [first_call] + + +def test_generate_and_print(instructor): + resp = instructor.post( + f"/instructor/day/{TODAY}/generate", + data={"n": "12"}, + follow_redirects=True, + ) + page = resp.get_data(as_text=True) + assert page.count("") == 13 # header + 12 rows + + +def test_regenerate_replaces_pending(instructor): + instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"}) + resp = instructor.post( + f"/instructor/day/{TODAY}/generate", + data={"n": "5"}, + follow_redirects=True, + ) + assert resp.get_data(as_text=True).count("") == 6 + + +def test_day_edit_saves_outcomes(instructor): + instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "2"}) + page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True) + ids = re.findall(r'name="status-(\d+)"', page) + assert len(ids) == 2 + + resp = instructor.post( + f"/instructor/day/{TODAY}", + data={ + f"status-{ids[0]}": "answered", + f"assessment-{ids[0]}": "POOR", + f"note-{ids[0]}": "rough day", + f"status-{ids[1]}": "pending", + f"delete-{ids[1]}": "on", + }, + follow_redirects=True, + ) + page = resp.get_data(as_text=True) + assert 'value="rough day"' in page + assert len(re.findall(r'name="status-(\d+)"', page)) == 1 + + +def test_outcome_rejects_bad_action_and_foreign_call(instructor): + instructor.post("/instructor/live/next", data={"date": TODAY}) + page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True) + call_id = re.search(r'/instructor/call/(\d+)/outcome', page).group(1) + + resp = instructor.post( + f"/instructor/call/{call_id}/outcome", data={"action": "nonsense"} + ) + assert resp.status_code == 400 + resp = instructor.post( + "/instructor/call/99999/outcome", data={"action": "GOOD"} + ) + assert resp.status_code == 404 + + +def test_settings_roundtrip(instructor): + resp = instructor.post( + "/instructor/settings", + data={"selection_mode": "cycle", "weight_factor": "3.0"}, + follow_redirects=True, + ) + page = resp.get_data(as_text=True) + assert '