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 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ from .models import (
|
|||||||
STATUS_SKIPPED,
|
STATUS_SKIPPED,
|
||||||
Call,
|
Call,
|
||||||
answered_call_counts,
|
answered_call_counts,
|
||||||
|
nonskipped_call_counts,
|
||||||
students_present,
|
students_present,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,15 +55,14 @@ def pick_next_student(db, course, day, rng=random):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if course.selection_mode == SELECTION_CYCLE:
|
if course.selection_mode == SELECTION_CYCLE:
|
||||||
# Everyone once per day, in random order: draw from those not
|
# The cycle runs across the whole course: everyone once, in
|
||||||
# yet called today, starting over once the roster is exhausted.
|
# random order, before anyone repeats; a new pass then starts
|
||||||
called_today = {
|
# automatically. Skipped calls don't count as called.
|
||||||
c.student_id
|
counts = nonskipped_call_counts(db, course.id)
|
||||||
for c in calls_for_day(db, course.id, day)
|
pool_ids = set(
|
||||||
if c.status != STATUS_SKIPPED
|
selection.cycle_pool([s.id for s in present], counts)
|
||||||
}
|
)
|
||||||
pool = [s for s in present if s.id not in called_today] or present
|
return rng.choice([s for s in present if s.id in pool_ids])
|
||||||
return rng.choice(pool)
|
|
||||||
|
|
||||||
by_id = {s.id: s for s in present}
|
by_id = {s.id: s for s in present}
|
||||||
counts = answered_call_counts(db, course.id)
|
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):
|
def generate_day_list(db, course, day, n=None, rng=random):
|
||||||
"""Create pending calls for a printable list. Cycle mode shuffles
|
"""Create pending calls for a printable list. Cycle mode takes the
|
||||||
the whole present roster; weighted mode draws n picks with
|
next n students of the current pass through the roster (short
|
||||||
within-list downweighting, like the old manual script."""
|
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)
|
present = students_present(db, course.id, day)
|
||||||
if not present:
|
if not present:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
ids = [s.id for s in present]
|
ids = [s.id for s in present]
|
||||||
if course.selection_mode == SELECTION_CYCLE:
|
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:
|
else:
|
||||||
counts = answered_call_counts(db, course.id)
|
counts = answered_call_counts(db, course.id)
|
||||||
ordered = selection.generate_call_list(
|
ordered = selection.generate_call_list(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .models import (
|
|||||||
SELECTION_WEIGHTED,
|
SELECTION_WEIGHTED,
|
||||||
STATUS_ANSWERED,
|
STATUS_ANSWERED,
|
||||||
STATUS_MISSING,
|
STATUS_MISSING,
|
||||||
|
STATUS_PENDING,
|
||||||
STATUS_SKIPPED,
|
STATUS_SKIPPED,
|
||||||
AssessmentLevel,
|
AssessmentLevel,
|
||||||
Call,
|
Call,
|
||||||
@@ -104,8 +105,18 @@ def home():
|
|||||||
.limit(10)
|
.limit(10)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
today_calls = calls.calls_for_day(db, course.id, today)
|
||||||
context = _day_context(db, course, 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)
|
return render_template("instructor_home.html", **context)
|
||||||
|
|
||||||
|
|
||||||
@@ -191,9 +202,17 @@ def day_print(date_str):
|
|||||||
course = current_course()
|
course = current_course()
|
||||||
day = _parse_date(date_str)
|
day = _parse_date(date_str)
|
||||||
day_calls = calls.calls_for_day(db, course.id, day)
|
day_calls = calls.calls_for_day(db, course.id, day)
|
||||||
return render_template(
|
context = _day_context(db, course, day)
|
||||||
"print_list.html", course=course, day=day, day_calls=day_calls
|
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/<date_str>")
|
@bp.get("/day/<date_str>")
|
||||||
@@ -202,15 +221,63 @@ def day_edit(date_str):
|
|||||||
db = get_db()
|
db = get_db()
|
||||||
course = current_course()
|
course = current_course()
|
||||||
day = _parse_date(date_str)
|
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 = _day_context(db, course, day)
|
||||||
context.update(
|
context.update(
|
||||||
day_calls=calls.calls_for_day(db, course.id, day),
|
day_calls=calls.calls_for_day(db, course.id, day),
|
||||||
levels=assessment_levels(db, course.id),
|
levels=assessment_levels(db, course.id),
|
||||||
statuses=CALL_STATUSES,
|
statuses=CALL_STATUSES,
|
||||||
|
roster=roster,
|
||||||
)
|
)
|
||||||
return render_template("day_edit.html", **context)
|
return render_template("day_edit.html", **context)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/day/<date_str>/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/<date_str>")
|
@bp.post("/day/<date_str>")
|
||||||
@require_instructor
|
@require_instructor
|
||||||
def day_save(date_str):
|
def day_save(date_str):
|
||||||
|
|||||||
@@ -320,6 +320,18 @@ def class_has_begun(session, course_id, day, now=None):
|
|||||||
return now.time() >= row.start_time
|
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):
|
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(
|
||||||
|
|||||||
@@ -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
|
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.
|
who has answered none. A factor of 1 makes selection uniform.
|
||||||
|
|
||||||
The alternative "cycle" mode shuffles the present students and calls
|
The alternative "cycle" mode goes through the roster in passes:
|
||||||
each exactly once, the equivalent of the old --shuffle flag.
|
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
|
import random
|
||||||
@@ -38,5 +41,20 @@ def generate_call_list(student_ids, prev_counts, n, weight_factor=2.0, rng=rando
|
|||||||
return picks
|
return picks
|
||||||
|
|
||||||
|
|
||||||
def generate_cycle_list(student_ids, rng=random):
|
def cycle_pool(student_ids, prev_counts):
|
||||||
return rng.sample(list(student_ids), len(student_ids))
|
"""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)
|
||||||
|
|||||||
@@ -41,4 +41,30 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<p>No calls recorded for this day.</p>
|
<p>No calls recorded for this day.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Add a call</h2>
|
||||||
|
<p class="muted">For recording a call after the fact — e.g. someone
|
||||||
|
you called on who wasn't on the list.</p>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('instructor.day_add_call', date_str=day.isoformat()) }}">
|
||||||
|
<select name="student_id" required>
|
||||||
|
<option value="">Student…</option>
|
||||||
|
{% for s in roster %}
|
||||||
|
<option value="{{ s.id }}">{{ s.sortable_name or s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="status">
|
||||||
|
{% for st in statuses %}
|
||||||
|
<option value="{{ st }}" {{ "selected" if st == "answered" }}>{{ st }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="assessment">
|
||||||
|
<option value="">assessment…</option>
|
||||||
|
{% for lvl in levels %}
|
||||||
|
<option value="{{ lvl.id }}">{{ lvl.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text" name="note" placeholder="note (optional)">
|
||||||
|
<button type="submit">Add call</button>
|
||||||
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -23,14 +23,24 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2>Printable list</h2>
|
<h2>Printable list</h2>
|
||||||
|
{% if has_pending_today %}
|
||||||
|
<p>
|
||||||
|
<a href="{{ url_for('instructor.day_print', date_str=today.isoformat()) }}">
|
||||||
|
View today's list</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}">
|
action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}"
|
||||||
<label>Number of calls (weighted mode only; cycle mode uses the whole
|
{% if resolved_today %}
|
||||||
roster): <input type="number" name="n" min="1" value="{{ present_count }}"></label>
|
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?');"
|
||||||
<button type="submit">Generate list for {{ today.isoformat() }}</button>
|
{% endif %}>
|
||||||
|
<label>Number of calls (in cycle mode, the next batch of the
|
||||||
|
rotation): <input type="number" name="n" min="1" value="{{ present_count }}"></label>
|
||||||
|
<button type="submit">
|
||||||
|
{{ "Regenerate list (replaces the current one)" if has_pending_today
|
||||||
|
else "Generate list for " + today.isoformat() }}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted">Generating replaces any unresolved calls already
|
|
||||||
pending for the day; recorded outcomes are kept.</p>
|
|
||||||
|
|
||||||
{% if recent_days %}
|
{% if recent_days %}
|
||||||
<h2>Recent days</h2>
|
<h2>Recent days</h2>
|
||||||
|
|||||||
@@ -6,6 +6,27 @@
|
|||||||
<a href="{{ url_for('instructor.day_edit', date_str=day.isoformat()) }}">edit outcomes</a>
|
<a href="{{ url_for('instructor.day_edit', date_str=day.isoformat()) }}">edit outcomes</a>
|
||||||
</p>
|
</p>
|
||||||
<h1>{{ course.title or course.lti_context_id }} — {{ day.isoformat() }}</h1>
|
<h1>{{ course.title or course.lti_context_id }} — {{ day.isoformat() }}</h1>
|
||||||
|
|
||||||
|
<div class="no-print">
|
||||||
|
{% if not has_pending %}
|
||||||
|
<p class="muted">No call list has been generated for this day yet
|
||||||
|
{%- if day_calls %} (the rows below are calls already recorded)
|
||||||
|
{%- endif %}.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('instructor.generate', date_str=day.isoformat()) }}"
|
||||||
|
{% if resolved_count %}
|
||||||
|
onsubmit="return confirm('{{ resolved_count }} call(s) on this day already have recorded outcomes. Those are kept, but the unused list lines are replaced by a fresh draw. Continue?');"
|
||||||
|
{% endif %}>
|
||||||
|
<label>Number of calls:
|
||||||
|
<input type="number" name="n" min="1" value="{{ present_count }}"></label>
|
||||||
|
<button type="submit">
|
||||||
|
{{ "Regenerate (replaces this list)" if has_pending
|
||||||
|
else "Generate list for " + day.isoformat() }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if day_calls %}
|
{% if day_calls %}
|
||||||
<table class="printlist">
|
<table class="printlist">
|
||||||
<tr><th>#</th><th></th><th>Student</th><th class="notes">Notes</th></tr>
|
<tr><th>#</th><th></th><th>Student</th><th class="notes">Notes</th></tr>
|
||||||
@@ -26,7 +47,11 @@
|
|||||||
<span class="muted">({{ c.student.sortable_name }})</span>
|
<span class="muted">({{ c.student.sortable_name }})</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="notes"></td>
|
<td class="notes">
|
||||||
|
{% if c.status != "pending" %}
|
||||||
|
<span class="muted">{{ c.assessment or c.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,8 +13,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span class="muted">weighted: students already called are less
|
<span class="muted">weighted: students already called are less
|
||||||
likely to be called again; cycle: everyone once per class in
|
likely to be called again; cycle: through the whole roster in
|
||||||
random order.</span>
|
random order — everyone once before anyone repeats, continuing
|
||||||
|
across lists and class days.</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label>Weight factor:
|
<label>Weight factor:
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ def test_generate_day_list_cycle_covers_roster(db_session):
|
|||||||
assert all(c.status == models.STATUS_PENDING for c in generated)
|
assert all(c.status == models.STATUS_PENDING for c in generated)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_day_list_cycle_batches_continue_across_days(db_session):
|
||||||
|
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
|
||||||
|
rng = random.Random(1)
|
||||||
|
day2 = DAY + datetime.timedelta(days=2)
|
||||||
|
|
||||||
|
first = calls.generate_day_list(db_session, course, DAY, n=3, rng=rng)
|
||||||
|
assert len(first) == 3
|
||||||
|
# Next day's batch continues the pass: the remaining student only.
|
||||||
|
second = calls.generate_day_list(db_session, course, day2, n=3, rng=rng)
|
||||||
|
assert len(second) == 1
|
||||||
|
assert sorted(c.student_id for c in first + second) == sorted(
|
||||||
|
s.id for s in students
|
||||||
|
)
|
||||||
|
# Pass complete: the next batch starts a fresh pass.
|
||||||
|
third = calls.generate_day_list(db_session, course, day2, n=3, rng=rng)
|
||||||
|
assert len(third) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_generate_day_list_weighted_length_and_optouts(db_session):
|
def test_generate_day_list_weighted_length_and_optouts(db_session):
|
||||||
course, students = make_course(db_session)
|
course, students = make_course(db_session)
|
||||||
db_session.add(
|
db_session.add(
|
||||||
|
|||||||
@@ -63,8 +63,16 @@ def test_generate_and_print(instructor):
|
|||||||
assert page.count("<tr>") == 13 # header + 12 rows
|
assert page.count("<tr>") == 13 # header + 12 rows
|
||||||
|
|
||||||
|
|
||||||
def test_regenerate_replaces_pending(instructor):
|
def test_regenerate_replaces_pending_and_labels_change(instructor):
|
||||||
|
page = instructor.get("/instructor/").get_data(as_text=True)
|
||||||
|
assert "Generate list for" in page
|
||||||
|
|
||||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"})
|
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"})
|
||||||
|
page = instructor.get("/instructor/").get_data(as_text=True)
|
||||||
|
assert "View today's list" in page
|
||||||
|
assert "Regenerate list" in page
|
||||||
|
|
||||||
|
# Regenerating replaces the unused list outright.
|
||||||
resp = instructor.post(
|
resp = instructor.post(
|
||||||
f"/instructor/day/{TODAY}/generate",
|
f"/instructor/day/{TODAY}/generate",
|
||||||
data={"n": "5"},
|
data={"n": "5"},
|
||||||
@@ -72,6 +80,47 @@ def test_regenerate_replaces_pending(instructor):
|
|||||||
)
|
)
|
||||||
assert resp.get_data(as_text=True).count("<tr>") == 6
|
assert resp.get_data(as_text=True).count("<tr>") == 6
|
||||||
|
|
||||||
|
# No recorded outcomes yet: no confirmation dialog wired up.
|
||||||
|
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
|
||||||
|
assert "onsubmit" not in page
|
||||||
|
|
||||||
|
# Record one outcome; the regenerate form now carries a warning.
|
||||||
|
edit = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||||
|
call_id = re.findall(r'name="status-(\d+)"', edit)[0]
|
||||||
|
instructor.post(
|
||||||
|
f"/instructor/day/{TODAY}",
|
||||||
|
data={f"status-{call_id}": "answered"},
|
||||||
|
)
|
||||||
|
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
|
||||||
|
assert "already have recorded outcomes" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_call_after_the_fact(instructor):
|
||||||
|
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||||
|
student_id = re.search(r'<option value="(\d+)">', page).group(1)
|
||||||
|
good_id = re.search(r'value="(\d+)">GOOD</option>', page).group(1)
|
||||||
|
|
||||||
|
resp = instructor.post(
|
||||||
|
f"/instructor/day/{TODAY}/add",
|
||||||
|
data={
|
||||||
|
"student_id": student_id,
|
||||||
|
"status": "answered",
|
||||||
|
"assessment": good_id,
|
||||||
|
"note": "volunteered",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
page = resp.get_data(as_text=True)
|
||||||
|
assert len(re.findall(r'name="status-(\d+)"', page)) == 1
|
||||||
|
assert 'value="volunteered"' in page
|
||||||
|
|
||||||
|
# Junk student ids are rejected.
|
||||||
|
resp = instructor.post(
|
||||||
|
f"/instructor/day/{TODAY}/add",
|
||||||
|
data={"student_id": "99999", "status": "answered"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_day_edit_saves_outcomes(instructor):
|
def test_day_edit_saves_outcomes(instructor):
|
||||||
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "2"})
|
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "2"})
|
||||||
|
|||||||
@@ -39,7 +39,28 @@ def test_generate_call_list_downweights_within_list():
|
|||||||
assert all(35 < counts[s] < 65 for s in STUDENTS)
|
assert all(35 < counts[s] < 65 for s in STUDENTS)
|
||||||
|
|
||||||
|
|
||||||
def test_generate_cycle_list_covers_everyone_once():
|
def test_generate_cycle_batch_covers_everyone_once():
|
||||||
rng = random.Random(1)
|
rng = random.Random(1)
|
||||||
picks = selection.generate_cycle_list(STUDENTS, rng)
|
picks = selection.generate_cycle_batch(STUDENTS, {}, None, rng)
|
||||||
assert sorted(picks) == sorted(STUDENTS)
|
assert sorted(picks) == sorted(STUDENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_cycle_batches_walk_the_pass():
|
||||||
|
rng = random.Random(1)
|
||||||
|
counts = {}
|
||||||
|
first = selection.generate_cycle_batch(STUDENTS, counts, 3, rng)
|
||||||
|
assert len(first) == 3
|
||||||
|
for s in first:
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
|
||||||
|
# The remainder batch is short, not wrapped into the next pass.
|
||||||
|
second = selection.generate_cycle_batch(STUDENTS, counts, 3, rng)
|
||||||
|
assert len(second) == 1
|
||||||
|
assert sorted(first + second) == sorted(STUDENTS)
|
||||||
|
for s in second:
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
|
||||||
|
# Everyone called once: a new pass opens with the full roster.
|
||||||
|
third = selection.generate_cycle_batch(STUDENTS, counts, 3, rng)
|
||||||
|
assert len(third) == 3
|
||||||
|
assert set(third) <= set(STUDENTS)
|
||||||
|
|||||||
Reference in New Issue
Block a user