Instructor participation report: per-student histograms, outcome mix by class day, and a sortable table including the fairness ratio (answered calls over questions present for, with opt-out days out of the denominator), plus CSV exports of students, calls, and opt-outs. Assessment scales are now per-course data: ordered levels with labels and points out of 100 (defaults carry the old R grading values), with calls referencing levels by id so renames follow through to history. Renaming, re-pointing, reordering, and adding levels are always allowed; deleting a level in use by recorded calls is blocked. Pronouns and course term dates come from Canvas custom variable substitutions, at launch and roster-wide via rlid-scoped NRPS; the student page notes that names/pronouns are Canvas-sourced. Rosters can also be refreshed outside launches: a "Sync roster now" button and a sync-rosters CLI command for an hourly cron job, skipping ended courses. Alembic now runs SQLite-compatible batch migrations with a constraint naming convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
170 lines
6.0 KiB
Python
170 lines
6.0 KiB
Python
import datetime
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from conftest import outcome_actions
|
|
|
|
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": outcome_actions(page)["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("<tr>") == 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("<tr>") == 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
|
|
poor_id = re.search(r'value="(\d+)"[^>]*>POOR</option>', page).group(1)
|
|
|
|
resp = instructor.post(
|
|
f"/instructor/day/{TODAY}",
|
|
data={
|
|
f"status-{ids[0]}": "answered",
|
|
f"assessment-{ids[0]}": poor_id,
|
|
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": "missing"}
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_assessment_scale_editing(instructor):
|
|
page = instructor.get("/instructor/settings").get_data(as_text=True)
|
|
ids = {
|
|
label: lid
|
|
for lid, label in re.findall(
|
|
r'name="label-(\d+)" value="([^"]+)"', page
|
|
)
|
|
}
|
|
assert set(ids) == {"GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER"}
|
|
|
|
# Record a call assessed GOOD so that level is in use.
|
|
record = instructor.post(
|
|
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
|
|
).get_data(as_text=True)
|
|
call_id = re.search(r"/instructor/call/(\d+)/outcome", record).group(1)
|
|
instructor.post(
|
|
f"/instructor/call/{call_id}/outcome",
|
|
data={"action": f"level-{ids['GOOD']}"},
|
|
)
|
|
|
|
# Rename and re-point the in-use level; delete an unused one; add one.
|
|
form = {"selection_mode": "weighted", "weight_factor": "2",
|
|
"show_assessments": "on",
|
|
"new_label": "HEROIC", "new_points": "110"}
|
|
for label, lid in ids.items():
|
|
form[f"label-{lid}"] = "EXCELLENT" if label == "GOOD" else label
|
|
form[f"points-{lid}"] = "90" if label == "GOOD" else "50"
|
|
form[f"position-{lid}"] = "0"
|
|
form[f"delete-{ids['POOR']}"] = "on"
|
|
form[f"delete-{ids['GOOD']}"] = "on" # in use: must survive
|
|
page = instructor.post(
|
|
"/instructor/settings", data=form, follow_redirects=True
|
|
).get_data(as_text=True)
|
|
|
|
assert 'value="EXCELLENT"' in page # renamed
|
|
assert 'value="90.0"' in page # re-pointed
|
|
assert "POOR" not in page # unused level deleted
|
|
assert re.search(r"in use \(1\s+call\)", page) # used level kept
|
|
assert "HEROIC" not in page # >100 points rejected
|
|
|
|
# The rename follows through to recorded calls.
|
|
day_page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
|
assert "EXCELLENT" in day_page
|
|
|
|
|
|
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 '<option value="cycle" selected>' in page
|
|
assert 'value="3.0"' in page
|
|
# Checkbox left unchecked turns assessment visibility off.
|
|
assert "checked" not in page
|