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.
+ {% if c.status != "pending" %}
+ {{ c.assessment or c.status }}
+ {% endif %}
+
{% endfor %}
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.