Ports the timing-neutral foregone-participation grading scheme from participation_grades.R: answer quality (per-course level points) minus a deduction for participation foregone through unavailability, estimated by Monte Carlo simulation of the actual weighted draw and averaged over when absences fall, plus a small form-filing incentive for drawn-while-absent-without-opt-out days. Reason-blind and luck-protected; zero-answer students floor to 0. Parameters (allowance in SD units, passing line, form penalty, simulation size/seed) are course settings. Verified against the R engine's rendered 2026q2 reports via the new import-legacy command: quality and availability match exactly, finals within Monte Carlo noise; dropped students import as inactive enrollments and are excluded identically. Grades are computed on demand into stored GradeRun snapshots and reviewed on a grades page with CSV export and per-student reports (also served to students via a publish toggle). Display scales map points to UW 4.0, a threshold table (one-click import of the Canvas course grading scheme), or raw points. Gradebook passback via AGS sits behind a settings toggle with a review-then-push flow. Opt-out withdrawals are now soft-deletes with a withdrawn_at audit trail, and close when class begins (class days gained optional start times), so availability records cannot be rewritten after the fact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.5 KiB
Python
189 lines
6.5 KiB
Python
import datetime
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from conftest import outcome_actions
|
|
|
|
TODAY = datetime.date.today()
|
|
|
|
|
|
@pytest.fixture
|
|
def student(dev_client):
|
|
# Instructor launch first so the dev course and roster exist.
|
|
dev_client.post("/dev/launch/dev-instructor")
|
|
dev_client.post("/dev/launch/dev-student-1")
|
|
return dev_client
|
|
|
|
|
|
def next_class_day_iso(page):
|
|
"""First offered opt-out date from the student page's select."""
|
|
match = re.search(r'<option value="(\d{4}-\d{2}-\d{2})"', page)
|
|
return match.group(1)
|
|
|
|
|
|
def test_student_home_shows_standing_and_histogram(student):
|
|
page = student.get("/me").get_data(as_text=True)
|
|
assert "answered <strong>0</strong>" in page
|
|
assert "class median is 0" in page
|
|
assert 'class="hist"' in page
|
|
assert "classmate" in page
|
|
|
|
|
|
def test_optout_roundtrip(student):
|
|
page = student.get("/me").get_data(as_text=True)
|
|
day = next_class_day_iso(page)
|
|
|
|
resp = student.post("/me/optout", data={"date": day}, follow_redirects=True)
|
|
page = resp.get_data(as_text=True)
|
|
assert f"<td>{day}</td>" in page
|
|
assert "Withdraw" in page
|
|
|
|
# Duplicate submissions are ignored, not errors.
|
|
student.post("/me/optout", data={"date": day})
|
|
page = student.get("/me").get_data(as_text=True)
|
|
assert page.count(f"<td>{day}</td>") == 1
|
|
|
|
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
|
|
resp = student.post(f"/me/optout/{optout_id}/delete", follow_redirects=True)
|
|
assert f"<td>{day}</td>" not in resp.get_data(as_text=True)
|
|
|
|
|
|
def test_optout_rejects_non_class_day(student):
|
|
# Dev course meets Tue/Thu; find an upcoming Monday.
|
|
day = TODAY + datetime.timedelta(days=1)
|
|
while day.weekday() != 0:
|
|
day += datetime.timedelta(days=1)
|
|
resp = student.post("/me/optout", data={"date": day.isoformat()})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_optout_rejects_past_date(student):
|
|
resp = student.post(
|
|
"/me/optout",
|
|
data={"date": (TODAY - datetime.timedelta(days=7)).isoformat()},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_optout_affects_instructor_present_count(student):
|
|
page = student.get("/me").get_data(as_text=True)
|
|
day = next_class_day_iso(page)
|
|
student.post("/me/optout", data={"date": day})
|
|
|
|
student.post("/dev/launch/dev-instructor")
|
|
page = student.get(f"/instructor/live?date={day}").get_data(as_text=True)
|
|
assert "7 of 8 students available" in page
|
|
assert "(1 opted out)" in page
|
|
|
|
|
|
def test_students_cannot_touch_others_optouts(student):
|
|
page = student.get("/me").get_data(as_text=True)
|
|
day = next_class_day_iso(page)
|
|
student.post("/me/optout", data={"date": day})
|
|
page = student.get("/me").get_data(as_text=True)
|
|
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
|
|
|
|
student.post("/dev/launch/dev-student-2")
|
|
assert student.post(f"/me/optout/{optout_id}/delete").status_code == 404
|
|
|
|
|
|
def test_withdrawal_locks_when_class_begins(student):
|
|
# Dev class days start at 23:59, so today's class hasn't begun and
|
|
# withdrawal works (covered by test_optout_roundtrip). Move a class
|
|
# day's start time into the past and withdrawal must be refused.
|
|
from coldcall_lti.models import ClassDay
|
|
|
|
page = student.get("/me").get_data(as_text=True)
|
|
day = next_class_day_iso(page)
|
|
student.post("/me/optout", data={"date": day})
|
|
page = student.get("/me").get_data(as_text=True)
|
|
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
|
|
|
|
# Reach into the DB to set that class day's start time to 00:00.
|
|
app = student.application
|
|
with app.app_context():
|
|
db = app.extensions["db_session_factory"]()
|
|
class_day = (
|
|
db.query(ClassDay)
|
|
.filter_by(date=datetime.date.fromisoformat(day))
|
|
.one()
|
|
)
|
|
began_today = class_day.date == datetime.date.today()
|
|
class_day.start_time = datetime.time(0, 0)
|
|
db.commit()
|
|
db.close()
|
|
|
|
resp = student.post(f"/me/optout/{optout_id}/delete")
|
|
if began_today:
|
|
assert resp.status_code == 400
|
|
page = student.get("/me").get_data(as_text=True)
|
|
assert "locked" in page
|
|
else:
|
|
# The day is still in the future; withdrawal stays open.
|
|
assert resp.status_code == 302
|
|
|
|
|
|
def test_withdrawal_is_recorded_not_deleted(student):
|
|
page = student.get("/me").get_data(as_text=True)
|
|
day = next_class_day_iso(page)
|
|
student.post("/me/optout", data={"date": day})
|
|
page = student.get("/me").get_data(as_text=True)
|
|
optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1)
|
|
student.post(f"/me/optout/{optout_id}/delete")
|
|
|
|
from coldcall_lti.models import OptOut
|
|
|
|
app = student.application
|
|
with app.app_context():
|
|
db = app.extensions["db_session_factory"]()
|
|
optout = db.query(OptOut).one()
|
|
assert optout.withdrawn_at is not None
|
|
db.close()
|
|
|
|
# Re-opting out reinstates the same row.
|
|
student.post("/me/optout", data={"date": day})
|
|
with app.app_context():
|
|
db = app.extensions["db_session_factory"]()
|
|
optout = db.query(OptOut).one()
|
|
assert optout.withdrawn_at is None
|
|
db.close()
|
|
|
|
# The instructor's opt-out export keeps the audit column.
|
|
student.post("/dev/launch/dev-instructor")
|
|
csv_text = student.get("/instructor/export/optouts.csv").get_data(as_text=True)
|
|
assert "withdrawn_at" in csv_text.splitlines()[0]
|
|
|
|
|
|
def test_assessment_visibility_setting(student):
|
|
# Record an answered call for dev-student-1 as the instructor.
|
|
student.post("/dev/launch/dev-instructor")
|
|
day = TODAY.isoformat()
|
|
# Draw live calls until dev-student-1 comes up, resolving each.
|
|
for _ in range(200):
|
|
student.post("/instructor/live/next", data={"date": day})
|
|
page = student.get(f"/instructor/live?date={day}").get_data(as_text=True)
|
|
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
|
|
target = "Ada Lovelace" in page
|
|
student.post(
|
|
f"/instructor/call/{call_id}/outcome",
|
|
data={"action": outcome_actions(page)["POOR"]},
|
|
)
|
|
if target:
|
|
break
|
|
|
|
student.post("/dev/launch/dev-student-1")
|
|
page = student.get("/me").get_data(as_text=True)
|
|
assert "POOR" in page
|
|
|
|
# Turn visibility off; the outcome shows as just "answered".
|
|
student.post("/dev/launch/dev-instructor")
|
|
student.post(
|
|
"/instructor/settings",
|
|
data={"selection_mode": "weighted", "weight_factor": "2"},
|
|
)
|
|
student.post("/dev/launch/dev-student-1")
|
|
page = student.get("/me").get_data(as_text=True)
|
|
assert "POOR" not in page
|
|
assert "answered" in page
|