From c7e1058d19ea9cbdae337d2d45d5c3ac37ae8f60 Mon Sep 17 00:00:00 2001 From: Benjamin Mako Hill Date: Fri, 31 Jul 2026 19:08:04 -0700 Subject: [PATCH] List lifecycle polish and cycle batches, from live walkthrough feedback Generating a call list is non-destructive by intent: the button reads Regenerate when an unused list exists and replaces it freely, with a confirmation only when the day already has recorded outcomes (which are always kept). The print view shows resolved calls' outcomes, offers generate/regenerate in place, and the home page links to the existing list. The day editor now supports adding calls after the fact (any roster student, with status/assessment/note), completing full editability: add, edit, delete. Cycle mode is now a true rolling rotation driven by non-skipped call counts: batches of n take the next students of the current pass, remainder batches come up short rather than wrapping, a new pass starts when everyone has been called, and the cycle continues across lists and class days. Live mode uses the same rule. Co-Authored-By: Claude Fable 5 --- coldcall_lti/calls.py | 30 +++++---- coldcall_lti/instructor.py | 73 ++++++++++++++++++++- coldcall_lti/models.py | 12 ++++ coldcall_lti/selection.py | 26 ++++++-- coldcall_lti/templates/day_edit.html | 26 ++++++++ coldcall_lti/templates/instructor_home.html | 22 +++++-- coldcall_lti/templates/print_list.html | 27 +++++++- coldcall_lti/templates/settings.html | 5 +- tests/test_calls.py | 18 +++++ tests/test_instructor_ui.py | 51 +++++++++++++- tests/test_selection.py | 25 ++++++- 11 files changed, 283 insertions(+), 32 deletions(-) diff --git a/coldcall_lti/calls.py b/coldcall_lti/calls.py index 815f24a..d2ff4ec 100644 --- a/coldcall_lti/calls.py +++ b/coldcall_lti/calls.py @@ -16,6 +16,7 @@ from .models import ( STATUS_SKIPPED, Call, answered_call_counts, + nonskipped_call_counts, students_present, ) @@ -54,15 +55,14 @@ def pick_next_student(db, course, day, rng=random): 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) + # The cycle runs across the whole course: everyone once, in + # random order, before anyone repeats; a new pass then starts + # automatically. Skipped calls don't count as called. + counts = nonskipped_call_counts(db, course.id) + pool_ids = set( + selection.cycle_pool([s.id for s in present], counts) + ) + return rng.choice([s for s in present if s.id in pool_ids]) by_id = {s.id: s for s in present} counts = answered_call_counts(db, course.id) @@ -88,16 +88,20 @@ def create_live_call(db, course, day, rng=random): 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.""" + """Create pending calls for a printable list. Cycle mode takes the + next n students of the current pass through the roster (short + remainder batches rather than spilling into the next pass); + 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) + ordered = selection.generate_cycle_batch( + ids, nonskipped_call_counts(db, course.id), n, rng + ) else: counts = answered_call_counts(db, course.id) ordered = selection.generate_call_list( diff --git a/coldcall_lti/instructor.py b/coldcall_lti/instructor.py index a89b5d5..f035fc3 100644 --- a/coldcall_lti/instructor.py +++ b/coldcall_lti/instructor.py @@ -23,6 +23,7 @@ from .models import ( SELECTION_WEIGHTED, STATUS_ANSWERED, STATUS_MISSING, + STATUS_PENDING, STATUS_SKIPPED, AssessmentLevel, Call, @@ -104,8 +105,18 @@ def home(): .limit(10) ).all() + today_calls = calls.calls_for_day(db, course.id, today) context = _day_context(db, course, today) - context.update(recent_days=recent_days, today=today) + context.update( + recent_days=recent_days, + today=today, + has_pending_today=any( + c.status == STATUS_PENDING for c in today_calls + ), + resolved_today=sum( + 1 for c in today_calls if c.status != STATUS_PENDING + ), + ) return render_template("instructor_home.html", **context) @@ -191,9 +202,17 @@ def day_print(date_str): 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 + context = _day_context(db, course, day) + context.update( + day_calls=day_calls, + has_pending=any( + c.status == STATUS_PENDING for c in day_calls + ), + resolved_count=sum( + 1 for c in day_calls if c.status != STATUS_PENDING + ), ) + return render_template("print_list.html", **context) @bp.get("/day/") @@ -202,15 +221,63 @@ def day_edit(date_str): db = get_db() course = current_course() day = _parse_date(date_str) + 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() context = _day_context(db, course, day) context.update( day_calls=calls.calls_for_day(db, course.id, day), levels=assessment_levels(db, course.id), statuses=CALL_STATUSES, + roster=roster, ) return render_template("day_edit.html", **context) +@bp.post("/day//add") +@require_instructor +def day_add_call(date_str): + """Add a call after the fact: someone was called (or should be + recorded as called) but isn't on the day's list.""" + db = get_db() + course = current_course() + day = _parse_date(date_str) + + student = db.get(Student, request.form.get("student_id", type=int) or 0) + enrolled = student is not None and db.execute( + select(Enrollment).where( + Enrollment.course_id == course.id, + Enrollment.student_id == student.id, + ) + ).scalar_one_or_none() is not None + if not enrolled: + abort(400, "Pick a student from this course's roster.") + + status = request.form.get("status") + if status not in CALL_STATUSES: + abort(400, "Bad status.") + assessment = request.form.get("assessment", type=int) + level_ids = {lvl.id for lvl in assessment_levels(db, course.id)} + call = Call( + course_id=course.id, + student_id=student.id, + session_date=day, + status=status, + assessment_id=assessment if assessment in level_ids else None, + note=(request.form.get("note", "").strip() or None), + ) + db.add(call) + db.commit() + return redirect(url_for(".day_edit", date_str=day.isoformat())) + + @bp.post("/day/") @require_instructor def day_save(date_str): diff --git a/coldcall_lti/models.py b/coldcall_lti/models.py index 4f0ab35..2a5109d 100644 --- a/coldcall_lti/models.py +++ b/coldcall_lti/models.py @@ -320,6 +320,18 @@ def class_has_begun(session, course_id, day, now=None): return now.time() >= row.start_time +def nonskipped_call_counts(session, course_id): + """Calls per student excluding skipped ones — a student's position + in the cycle-mode pass. Pending list slots count: a printed line + is a claim on being called.""" + rows = session.execute( + select(Call.student_id, func.count()) + .where(Call.course_id == course_id, Call.status != STATUS_SKIPPED) + .group_by(Call.student_id) + ).all() + return dict(rows) + + 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/selection.py b/coldcall_lti/selection.py index 695775a..5c9a82f 100644 --- a/coldcall_lti/selection.py +++ b/coldcall_lti/selection.py @@ -6,8 +6,11 @@ course's weight factor, so with the default factor of 2 a student who has answered n questions is 2^n times less likely to be picked than one who has answered none. A factor of 1 makes selection uniform. -The alternative "cycle" mode shuffles the present students and calls -each exactly once, the equivalent of the old --shuffle flag. +The alternative "cycle" mode goes through the roster in passes: +everyone is called once, in random order, before anyone is called +again. Batches of any size draw the next students in the current +pass; a batch never spills into the next pass, so a request for more +students than the pass has left simply returns the remainder. """ import random @@ -38,5 +41,20 @@ def generate_call_list(student_ids, prev_counts, n, weight_factor=2.0, rng=rando return picks -def generate_cycle_list(student_ids, rng=random): - return rng.sample(list(student_ids), len(student_ids)) +def cycle_pool(student_ids, prev_counts): + """Who is still uncalled in the current pass: the students whose + (non-skipped) call count is at the minimum.""" + ids = list(student_ids) + if not ids: + return [] + min_count = min(prev_counts.get(s, 0) for s in ids) + return [s for s in ids if prev_counts.get(s, 0) == min_count] + + +def generate_cycle_batch(student_ids, prev_counts, n=None, rng=random): + """The next n students of the current cycle pass, in random order. + With n=None, the whole remainder of the pass. Never spills into + the next pass: a short remainder yields a short batch.""" + pool = cycle_pool(student_ids, prev_counts) + k = len(pool) if n is None else min(n, len(pool)) + return rng.sample(pool, k) diff --git a/coldcall_lti/templates/day_edit.html b/coldcall_lti/templates/day_edit.html index f2b1545..7e16509 100644 --- a/coldcall_lti/templates/day_edit.html +++ b/coldcall_lti/templates/day_edit.html @@ -41,4 +41,30 @@ {% else %}

No calls recorded for this day.

{% endif %} + +

Add a call

+

For recording a call after the fact — e.g. someone + you called on who wasn't on the list.

+
+ + + + + +
{% endblock %} diff --git a/coldcall_lti/templates/instructor_home.html b/coldcall_lti/templates/instructor_home.html index 40eb520..9509c9d 100644 --- a/coldcall_lti/templates/instructor_home.html +++ b/coldcall_lti/templates/instructor_home.html @@ -23,14 +23,24 @@

Printable list

+{% if has_pending_today %} +

+ + View today's list +

+{% endif %}
- - + action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}" + {% if resolved_today %} + onsubmit="return confirm('{{ resolved_today }} call(s) today already have recorded outcomes. Those are kept, but the unused list lines are replaced by a fresh draw. Continue?');" + {% endif %}> + +
-

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

{% if recent_days %}

Recent days

diff --git a/coldcall_lti/templates/print_list.html b/coldcall_lti/templates/print_list.html index a5b2abf..c02f173 100644 --- a/coldcall_lti/templates/print_list.html +++ b/coldcall_lti/templates/print_list.html @@ -6,6 +6,27 @@ edit outcomes

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

+ +
+ {% if not has_pending %} +

No call list has been generated for this day yet + {%- if day_calls %} (the rows below are calls already recorded) + {%- endif %}.

+ {% endif %} +
+ + +
+
+ {% if day_calls %} @@ -26,7 +47,11 @@ ({{ c.student.sortable_name }}) {% endif %} - + {% endfor %}
#StudentNotes
+ {% if c.status != "pending" %} + {{ c.assessment or c.status }} + {% endif %} +
diff --git a/coldcall_lti/templates/settings.html b/coldcall_lti/templates/settings.html index 9f28ed8..c74f31b 100644 --- a/coldcall_lti/templates/settings.html +++ b/coldcall_lti/templates/settings.html @@ -13,8 +13,9 @@ weighted: students already called are less - likely to be called again; cycle: everyone once per class in - random order. + likely to be called again; cycle: through the whole roster in + random order — everyone once before anyone repeats, continuing + across lists and class days.