Phase 6: participation grading, gradebook passback, opt-out integrity
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>
This commit is contained in:
47
README.md
47
README.md
@@ -103,6 +103,7 @@ configuration: add a custom parameter
|
||||
pronouns=$com.instructure.Person.pronouns
|
||||
course_start=$Canvas.course.startAt
|
||||
course_end=$Canvas.course.endAt
|
||||
grading_scheme=$com.instructure.Course.gradingScheme
|
||||
```
|
||||
|
||||
The course dates bound the schedule and date pickers; the tool works
|
||||
@@ -115,6 +116,52 @@ fields). Pronouns appear on the live call card, printed call lists,
|
||||
and each student's own page. If the account has pronouns disabled,
|
||||
everything simply shows without them.
|
||||
|
||||
## Opt-outs
|
||||
|
||||
Students remove themselves from a day's cold-call list by picking the
|
||||
date on their page; nothing else is asked. There is deliberately no
|
||||
enforced submission deadline: an instructor running live calls in
|
||||
class gets up-to-the-second opt-outs automatically, while one who
|
||||
prints a call list beforehand should just tell students how much lead
|
||||
time they need (for example, "an hour or two before class"), since
|
||||
opt-outs after printing won't be on the paper.
|
||||
|
||||
Withdrawing an opt-out is bounded, because un-opting-out after the
|
||||
fact would rewrite a student's availability record (and with it their
|
||||
participation grade): withdrawal closes when class begins, using the
|
||||
start time recorded on the schedule page, or at the start of the class
|
||||
day when no time is recorded. Withdrawals are also recorded rather
|
||||
than deleted — the opt-out export includes a withdrawn_at column, so
|
||||
the full history of changes survives.
|
||||
|
||||
## Grading
|
||||
|
||||
Final participation grades use the timing-neutral foregone-
|
||||
participation scheme (a port of the earlier participation_grades.R):
|
||||
a student's grade is the quality of their answers minus a deduction
|
||||
for participation they missed by being unavailable, estimated by
|
||||
Monte Carlo simulation of the actual weighted draw and averaged over
|
||||
when the absences fall, plus a small incentive penalty for being
|
||||
drawn while absent with no opt-out filed. The reason for an absence
|
||||
never matters, and luck of the draw never moves a grade. Parameters
|
||||
(allowance, passing line, penalties, simulation size) are per-course
|
||||
settings; grades are computed on demand, reviewed on the instructor's
|
||||
grades page, and shown to students only when the instructor publishes
|
||||
reports. With gradebook passback enabled, a review-then-push page
|
||||
sends the reviewed scores to Canvas via the Assignment and Grade
|
||||
Services; nothing is ever sent without explicit confirmation.
|
||||
|
||||
Grades are computed in points out of 100 and displayed through a
|
||||
per-course scale: the built-in linear UW 4.0 map, a threshold table
|
||||
(letter grades, importable in one click from the course's own Canvas
|
||||
grading scheme), or raw points.
|
||||
|
||||
The port was verified against the R engine's rendered reports from a
|
||||
real course: answer quality and availability match exactly; the
|
||||
simulated penalty agrees within Monte Carlo noise. `flask --app
|
||||
coldcall_lti import-legacy <dir>` imports a manual-era class directory
|
||||
for this kind of testing.
|
||||
|
||||
## Developing without Canvas
|
||||
|
||||
Because a Developer Key takes institutional approval to get, the app
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Flask
|
||||
@@ -23,11 +24,14 @@ def create_app(config=Config):
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "None"
|
||||
app.config["SESSION_COOKIE_SECURE"] = True
|
||||
|
||||
from . import instructor, lti, views
|
||||
app.template_filter("fromjson")(json.loads)
|
||||
|
||||
from . import grades, instructor, lti, views
|
||||
|
||||
app.register_blueprint(lti.bp)
|
||||
app.register_blueprint(views.bp)
|
||||
app.register_blueprint(instructor.bp)
|
||||
app.register_blueprint(grades.bp)
|
||||
|
||||
if app.config["DEV_MODE"]:
|
||||
from . import dev
|
||||
|
||||
@@ -6,17 +6,59 @@ launches (enrollment shifts constantly in the first weeks of a term):
|
||||
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
|
||||
"""
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import click
|
||||
from flask import current_app
|
||||
from sqlalchemy import select
|
||||
|
||||
from .models import Course
|
||||
from .models import (
|
||||
AssessmentLevel,
|
||||
Call,
|
||||
ClassDay,
|
||||
Course,
|
||||
Enrollment,
|
||||
OptOut,
|
||||
Student,
|
||||
ensure_default_levels,
|
||||
)
|
||||
from .roster import sync_roster
|
||||
|
||||
# Exact point values from participation_grades.R / the older grading
|
||||
# scripts (one UW grade step = 50/3.3 points).
|
||||
_GPA_STEP = 50 / (4 - 0.7)
|
||||
LEGACY_LEVEL_POINTS = {
|
||||
"GOOD": 100.0,
|
||||
"OKAY": 100 - 0.7 * _GPA_STEP,
|
||||
"SATISFACTORY": 100 - _GPA_STEP,
|
||||
"FAIR": 100 - _GPA_STEP,
|
||||
"POOR": 100 - 2 * _GPA_STEP,
|
||||
"BAD": 100 - 2 * _GPA_STEP,
|
||||
"NO MEANINGFUL ANSWER": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def register_cli(app):
|
||||
@app.cli.command("import-legacy")
|
||||
@click.argument("directory")
|
||||
@click.option("--context-id", default=None,
|
||||
help="LTI context id for the created course")
|
||||
def import_legacy(directory, context_id):
|
||||
"""One-off import of a manual-era class directory (call_list
|
||||
TSVs, opt-out TSV, student info TSV) for testing and for
|
||||
verifying the grading port against old outputs."""
|
||||
db = current_app.extensions["db_session_factory"]()
|
||||
try:
|
||||
_import_legacy(db, directory, context_id)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.cli.command("sync-rosters")
|
||||
def sync_rosters():
|
||||
"""Refresh every syncable course roster from its LMS."""
|
||||
@@ -48,3 +90,156 @@ def register_cli(app):
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _read_tsv(path):
|
||||
# Tolerate hand-edited files: CRLF line endings and stray blank
|
||||
# lines (splitlines handles \r\n; blank lines are dropped).
|
||||
with open(path, newline="") as f:
|
||||
lines = [l for l in f.read().splitlines() if l.strip()]
|
||||
return list(csv.DictReader(lines, delimiter="\t"))
|
||||
|
||||
|
||||
def _import_legacy(db, directory, context_id):
|
||||
with open(os.path.join(directory, "data", "configuration.json")) as f:
|
||||
config = json.load(f)
|
||||
unique_col = config["unique_name_rowname"]
|
||||
preferred_col = config.get("preferred_name_rowname")
|
||||
|
||||
context_id = context_id or f"legacy-{os.path.basename(os.path.abspath(directory))}"
|
||||
course = Course(lti_context_id=context_id, title=f"Imported: {context_id}")
|
||||
db.add(course)
|
||||
db.flush()
|
||||
ensure_default_levels(db, course)
|
||||
|
||||
# Students and enrollments from the student-info sheet: that is the
|
||||
# enrolled set the R pipeline (track_participation.R) used.
|
||||
info = _read_tsv(os.path.join(directory, config["student_info_file"]))
|
||||
students = {}
|
||||
for row in info:
|
||||
uid = row[unique_col].strip()
|
||||
if not uid or uid in students:
|
||||
continue
|
||||
student = Student(
|
||||
canvas_user_id=f"legacy-{context_id}-{uid}",
|
||||
name=(row.get(preferred_col) or "").strip() or uid,
|
||||
)
|
||||
db.add(student)
|
||||
db.flush()
|
||||
db.add(Enrollment(course_id=course.id, student_id=student.id))
|
||||
students[uid] = student
|
||||
db.flush()
|
||||
|
||||
levels = {
|
||||
lvl.label: lvl
|
||||
for lvl in db.execute(
|
||||
select(AssessmentLevel).where(
|
||||
AssessmentLevel.course_id == course.id
|
||||
)
|
||||
).scalars()
|
||||
}
|
||||
|
||||
def level_for(label):
|
||||
label = label.strip()
|
||||
if not label or label in ("MISSING", "NA"):
|
||||
return None
|
||||
if label not in levels:
|
||||
points = LEGACY_LEVEL_POINTS.get(label)
|
||||
if points is None:
|
||||
click.echo(f"unknown assessment {label!r}: created at 50.0",
|
||||
err=True)
|
||||
points = 50.0
|
||||
lvl = AssessmentLevel(
|
||||
course_id=course.id, label=label, points=round(points, 4),
|
||||
position=len(levels),
|
||||
)
|
||||
db.add(lvl)
|
||||
db.flush()
|
||||
levels[label] = lvl
|
||||
return levels[label]
|
||||
|
||||
# Make sure legacy labels carry the R scripts' exact point values,
|
||||
# even where they collide with seeded defaults.
|
||||
for label, lvl in levels.items():
|
||||
if label in LEGACY_LEVEL_POINTS:
|
||||
lvl.points = round(LEGACY_LEVEL_POINTS[label], 4)
|
||||
|
||||
# Students who appear in call lists but not on the info sheet
|
||||
# dropped the class: imported as inactive enrollments with their
|
||||
# call history kept, exactly what an NRPS sync would produce.
|
||||
dropped = set()
|
||||
|
||||
def dropped_student(uid):
|
||||
dropped.add(uid)
|
||||
student = Student(
|
||||
canvas_user_id=f"legacy-{context_id}-{uid}", name=uid
|
||||
)
|
||||
db.add(student)
|
||||
db.flush()
|
||||
db.add(
|
||||
Enrollment(
|
||||
course_id=course.id, student_id=student.id, active=False
|
||||
)
|
||||
)
|
||||
students[uid] = student
|
||||
return student
|
||||
|
||||
n_calls = 0
|
||||
days = set()
|
||||
for path in sorted(
|
||||
glob.glob(os.path.join(directory, "data", "call_list-*.tsv"))
|
||||
):
|
||||
day = datetime.date.fromisoformat(
|
||||
re.search(r"call_list-(\d{4}-\d{2}-\d{2})", path).group(1)
|
||||
)
|
||||
for row in _read_tsv(path):
|
||||
uid = row[unique_col].strip()
|
||||
student = students.get(uid) or dropped_student(uid)
|
||||
answered = row["answered"].strip().upper()
|
||||
if answered not in ("TRUE", "FALSE"):
|
||||
continue # never resolved
|
||||
lvl = level_for(row.get("assessment", ""))
|
||||
db.add(
|
||||
Call(
|
||||
course_id=course.id,
|
||||
student_id=student.id,
|
||||
session_date=day,
|
||||
status="answered" if answered == "TRUE" else "missing",
|
||||
assessment_id=lvl.id if (lvl and answered == "TRUE") else None,
|
||||
)
|
||||
)
|
||||
days.add(day)
|
||||
n_calls += 1
|
||||
db.flush()
|
||||
|
||||
for day in sorted(days):
|
||||
db.add(ClassDay(course_id=course.id, date=day))
|
||||
|
||||
n_optouts = 0
|
||||
optout_path = os.path.join(directory, config["optout_file"])
|
||||
if os.path.exists(optout_path):
|
||||
seen = set()
|
||||
for row in _read_tsv(optout_path):
|
||||
uid = row[unique_col].strip()
|
||||
student = students.get(uid)
|
||||
date_str = row.get(
|
||||
"Date of class session you will be absent", ""
|
||||
).strip()
|
||||
if student is None or not date_str:
|
||||
continue
|
||||
month, day_n, year = (int(x) for x in date_str.split("/"))
|
||||
date = datetime.date(year, month, day_n)
|
||||
if (student.id, date) in seen:
|
||||
continue
|
||||
seen.add((student.id, date))
|
||||
db.add(
|
||||
OptOut(course_id=course.id, student_id=student.id, date=date)
|
||||
)
|
||||
n_optouts += 1
|
||||
db.flush()
|
||||
|
||||
click.echo(
|
||||
f"imported {context_id}: {len(students) - len(dropped)} active "
|
||||
f"students, {len(dropped)} dropped (inactive), {n_calls} calls "
|
||||
f"over {len(days)} days, {n_optouts} opt-outs"
|
||||
)
|
||||
|
||||
@@ -76,14 +76,21 @@ def _ensure_dev_course(db):
|
||||
ensure_default_levels(db, course)
|
||||
sync_roster(db, course, DEV_MEMBERS)
|
||||
|
||||
# A Tue/Thu meeting pattern for the next few weeks, so the opt-out
|
||||
# picker and schedule pages have something to show.
|
||||
# A Tue/Thu 23:59 meeting pattern for the next few weeks, so the
|
||||
# opt-out picker and schedule pages have something to show and
|
||||
# same-day withdrawal is exercisable in dev.
|
||||
if not class_days(db, course.id):
|
||||
day = datetime.date.today()
|
||||
for offset in range(28):
|
||||
d = day + datetime.timedelta(days=offset)
|
||||
if d.weekday() in (1, 3):
|
||||
db.add(ClassDay(course_id=course.id, date=d))
|
||||
db.add(
|
||||
ClassDay(
|
||||
course_id=course.id,
|
||||
date=d,
|
||||
start_time=datetime.time(23, 59),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return course
|
||||
|
||||
|
||||
217
coldcall_lti/grades.py
Normal file
217
coldcall_lti/grades.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
redirect,
|
||||
render_template,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from . import grading, scales
|
||||
from .db import get_db
|
||||
from .models import Call, Student, latest_grade_run
|
||||
from .views import current_course, require_instructor
|
||||
|
||||
bp = Blueprint("grades", __name__, url_prefix="/instructor/grades")
|
||||
|
||||
|
||||
def _run_results(db, course):
|
||||
run = latest_grade_run(db, course.id)
|
||||
if run is None:
|
||||
return None, None
|
||||
return run, json.loads(run.results)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@require_instructor
|
||||
def view():
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
run, results = _run_results(db, course)
|
||||
return render_template(
|
||||
"grades.html",
|
||||
course=course,
|
||||
run=run,
|
||||
results=results,
|
||||
display=lambda pts: scales.display(course, pts),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/compute")
|
||||
@require_instructor
|
||||
def compute():
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
grading.run_and_store(db, course)
|
||||
db.commit()
|
||||
return redirect(url_for(".view"))
|
||||
|
||||
|
||||
@bp.get("/export.csv")
|
||||
@require_instructor
|
||||
def export():
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
run, results = _run_results(db, course)
|
||||
if not results or "students" not in results:
|
||||
abort(404)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(
|
||||
["name", "asked", "days_missed", "q_points", "foregone",
|
||||
"caught", "final_points", "display_grade"]
|
||||
)
|
||||
for r in results["students"]:
|
||||
writer.writerow(
|
||||
[r["name"], r["asked"], r["days_missed"], r["q_points"],
|
||||
r["foregone"], r["caught"], r["final_points"],
|
||||
scales.display(course, r["final_points"])]
|
||||
)
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=grades.csv"},
|
||||
)
|
||||
|
||||
|
||||
def student_report_context(db, course, results, student_id):
|
||||
row = next(
|
||||
(r for r in results["students"] if r["student_id"] == student_id),
|
||||
None,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
student = db.get(Student, student_id)
|
||||
calls = (
|
||||
db.query(Call)
|
||||
.filter(
|
||||
Call.course_id == course.id,
|
||||
Call.student_id == student_id,
|
||||
Call.status.in_(["answered", "missing"]),
|
||||
)
|
||||
.order_by(Call.session_date, Call.id)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"course": course,
|
||||
"student": student,
|
||||
"row": row,
|
||||
"calls": calls,
|
||||
"results": results,
|
||||
"final_display": scales.display(course, row["final_points"]),
|
||||
"q_display": scales.display(course, row["q_points"]),
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/student/<int:student_id>")
|
||||
@require_instructor
|
||||
def student_report(student_id):
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
run, results = _run_results(db, course)
|
||||
if not results or "students" not in results:
|
||||
abort(404)
|
||||
context = student_report_context(db, course, results, student_id)
|
||||
if context is None:
|
||||
abort(404)
|
||||
return render_template(
|
||||
"grade_report.html", is_instructor_view=True, **context
|
||||
)
|
||||
|
||||
|
||||
def _push_rows(db, course, results):
|
||||
students = {
|
||||
s.id: s
|
||||
for s in db.query(Student)
|
||||
.filter(Student.id.in_([r["student_id"] for r in results["students"]]))
|
||||
}
|
||||
rows = []
|
||||
for r in results["students"]:
|
||||
score, maximum = scales.ags_score(course, r["final_points"])
|
||||
rows.append(
|
||||
{
|
||||
"row": r,
|
||||
"student": students[r["student_id"]],
|
||||
"score": score,
|
||||
"maximum": maximum,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@bp.get("/push")
|
||||
@require_instructor
|
||||
def push_preview():
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
if not course.ags_enabled:
|
||||
abort(404)
|
||||
run, results = _run_results(db, course)
|
||||
if not results or "students" not in results:
|
||||
abort(404)
|
||||
return render_template(
|
||||
"push_preview.html",
|
||||
course=course,
|
||||
run=run,
|
||||
rows=_push_rows(db, course, results),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/push")
|
||||
@require_instructor
|
||||
def push():
|
||||
from pylti1p3.grade import Grade
|
||||
from pylti1p3.lineitem import LineItem
|
||||
|
||||
from .lti import ags_service_for_course
|
||||
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
if not course.ags_enabled:
|
||||
abort(404)
|
||||
run, results = _run_results(db, course)
|
||||
if not results or "students" not in results:
|
||||
abort(404)
|
||||
rows = _push_rows(db, course, results)
|
||||
|
||||
service = ags_service_for_course(course)
|
||||
outcomes = []
|
||||
if service is None:
|
||||
if not current_app.config["DEV_MODE"]:
|
||||
abort(400, "No gradebook service recorded for this course.")
|
||||
outcomes = [
|
||||
{"name": r["row"]["name"], "ok": True, "detail": "simulated (dev mode)"}
|
||||
for r in rows
|
||||
]
|
||||
else:
|
||||
lineitem = LineItem()
|
||||
lineitem.set_tag("coldcall-participation")
|
||||
lineitem.set_label("Case discussion participation")
|
||||
lineitem.set_score_maximum(rows[0]["maximum"] if rows else 100)
|
||||
lineitem = service.find_or_create_lineitem(lineitem)
|
||||
timestamp = datetime.datetime.now(datetime.UTC).isoformat()
|
||||
for r in rows:
|
||||
grade = Grade()
|
||||
grade.set_score_given(r["score"])
|
||||
grade.set_score_maximum(r["maximum"])
|
||||
grade.set_user_id(r["student"].canvas_user_id)
|
||||
grade.set_timestamp(timestamp)
|
||||
grade.set_activity_progress("Completed")
|
||||
grade.set_grading_progress("FullyGraded")
|
||||
try:
|
||||
service.put_grade(grade, lineitem)
|
||||
outcomes.append({"name": r["row"]["name"], "ok": True,
|
||||
"detail": f"{r['score']}"})
|
||||
except Exception as exc:
|
||||
outcomes.append({"name": r["row"]["name"], "ok": False,
|
||||
"detail": str(exc)})
|
||||
|
||||
return render_template(
|
||||
"push_result.html", course=course, outcomes=outcomes
|
||||
)
|
||||
270
coldcall_lti/grading.py
Normal file
270
coldcall_lti/grading.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Participation grading: the timing-neutral "foregone participation"
|
||||
scheme, ported from participation_grades.R.
|
||||
|
||||
A student's grade is the quality of their answers (mean points over
|
||||
answered calls) minus a deduction for expected participation foregone
|
||||
through unavailability, minus a small form-filing incentive. Foregone
|
||||
participation is estimated by Monte Carlo simulation of the actual
|
||||
weighted cold-call draw, averaged over which days are missed, so the
|
||||
grade depends on how much was missed, not when. Realized call counts
|
||||
never enter the penalty: luck is never graded.
|
||||
|
||||
Everything internal is in points out of 100; display scales map at the
|
||||
edge (scales.py).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy import select
|
||||
|
||||
from .models import (
|
||||
STATUS_ANSWERED,
|
||||
STATUS_MISSING,
|
||||
Call,
|
||||
Enrollment,
|
||||
GradeRun,
|
||||
OptOut,
|
||||
Student,
|
||||
)
|
||||
|
||||
|
||||
def gather_inputs(db, course):
|
||||
"""Everything the engine needs, from the database."""
|
||||
enrolled = [
|
||||
student
|
||||
for (student,) in 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)
|
||||
)
|
||||
]
|
||||
calls = (
|
||||
db.execute(
|
||||
select(Call)
|
||||
.where(
|
||||
Call.course_id == course.id,
|
||||
Call.status.in_([STATUS_ANSWERED, STATUS_MISSING]),
|
||||
)
|
||||
.order_by(Call.session_date, Call.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
days = sorted({c.session_date for c in calls})
|
||||
enrolled_ids = {s.id for s in enrolled}
|
||||
|
||||
answered_by_day = defaultdict(int)
|
||||
points = defaultdict(list)
|
||||
missing_days = defaultdict(set)
|
||||
excluded_students = set()
|
||||
excluded_calls = 0
|
||||
for c in calls:
|
||||
if c.student_id not in enrolled_ids:
|
||||
# Dropped students: their calls happened but count nowhere,
|
||||
# matching the R pipeline's non-roster handling.
|
||||
excluded_students.add(c.student_id)
|
||||
excluded_calls += 1
|
||||
continue
|
||||
if c.status == STATUS_ANSWERED:
|
||||
answered_by_day[c.session_date] += 1
|
||||
if c.level is not None:
|
||||
points[c.student_id].append(c.level.points)
|
||||
else:
|
||||
missing_days[c.student_id].add(c.session_date)
|
||||
|
||||
optout_days = defaultdict(set)
|
||||
for o in db.execute(
|
||||
select(OptOut).where(
|
||||
OptOut.course_id == course.id, OptOut.withdrawn_at.is_(None)
|
||||
)
|
||||
).scalars():
|
||||
optout_days[o.student_id].add(o.date)
|
||||
|
||||
return {
|
||||
"enrolled": enrolled,
|
||||
"days": days,
|
||||
"calls_per_day": [answered_by_day[d] for d in days],
|
||||
"points": dict(points),
|
||||
"missing_days": dict(missing_days),
|
||||
"optout_days": dict(optout_days),
|
||||
"excluded_students": len(excluded_students),
|
||||
"excluded_calls": excluded_calls,
|
||||
}
|
||||
|
||||
|
||||
def _simulate_counts(present, focal_present, calls_per_day, weight_factor,
|
||||
n_sims, rng):
|
||||
"""How many questions the focal student (index 0) answers, across
|
||||
n_sims simulated terms. `present` is a bool matrix (students x
|
||||
days) for everyone but the focal student's own row, which is taken
|
||||
from `focal_present` (n_sims x days, so each simulation can have a
|
||||
different absence pattern for the focal student)."""
|
||||
n_students, n_days = present.shape
|
||||
weights = np.ones((n_sims, n_students))
|
||||
counts = np.zeros(n_sims, dtype=int)
|
||||
|
||||
for di in range(n_days):
|
||||
k = calls_per_day[di]
|
||||
if k <= 0:
|
||||
continue
|
||||
pool = np.flatnonzero(present[:, di])
|
||||
if pool.size == 0:
|
||||
continue
|
||||
focal_in_pool = pool[0] == 0 if pool.size else False
|
||||
focal_mask = focal_present[:, di]
|
||||
for _ in range(k):
|
||||
w = weights[:, pool]
|
||||
if focal_in_pool:
|
||||
w = w.copy()
|
||||
w[:, 0] = w[:, 0] * focal_mask
|
||||
cw = np.cumsum(w, axis=1)
|
||||
total = cw[:, -1]
|
||||
# Rows with an empty effective pool can't happen: the focal
|
||||
# row only zeroes when others remain (pool includes them).
|
||||
u = rng.random(n_sims) * total
|
||||
idx = (cw < u[:, None]).sum(axis=1)
|
||||
chosen = pool[idx]
|
||||
weights[np.arange(n_sims), chosen] /= weight_factor
|
||||
counts += (chosen == 0) & (
|
||||
focal_mask if focal_in_pool else np.zeros(n_sims, bool)
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def compute(db, course):
|
||||
"""Run the full grading computation; returns a JSON-able dict."""
|
||||
inputs = gather_inputs(db, course)
|
||||
enrolled = inputs["enrolled"]
|
||||
days = inputs["days"]
|
||||
n = len(enrolled)
|
||||
n_days = len(days)
|
||||
calls_per_day = inputs["calls_per_day"]
|
||||
total_answered = sum(calls_per_day)
|
||||
|
||||
params = {
|
||||
"allowance_sd_units": course.allowance_sd_units,
|
||||
"passing_points": course.passing_points,
|
||||
"form_penalty_points": course.form_penalty_points,
|
||||
"weight_factor": course.weight_factor,
|
||||
"n_sims": course.n_sims,
|
||||
"seed": course.sim_seed,
|
||||
}
|
||||
|
||||
if n == 0 or total_answered == 0:
|
||||
return {
|
||||
"params": params,
|
||||
"error": "No enrolled students or no answered calls yet.",
|
||||
}
|
||||
|
||||
rng = np.random.default_rng(course.sim_seed)
|
||||
n_sims = course.n_sims
|
||||
|
||||
# Availability: reason-blind union of opt-outs and drawn-but-missing
|
||||
# days. Row 0 is the focal slot, overwritten per simulation.
|
||||
unavailable = {
|
||||
s.id: inputs["optout_days"].get(s.id, set())
|
||||
| inputs["missing_days"].get(s.id, set())
|
||||
for s in enrolled
|
||||
}
|
||||
present = np.array(
|
||||
[[d not in unavailable[s.id] for d in days] for s in enrolled]
|
||||
)
|
||||
days_missed = [(~present[i]).sum() for i in range(n)]
|
||||
|
||||
always = np.ones((n_sims, n_days), dtype=bool)
|
||||
|
||||
# SD of a fully-attending class: the draw's natural spread.
|
||||
full = np.ones((n, n_days), dtype=bool)
|
||||
sd_full = float(np.std(
|
||||
_simulate_counts(full, always, calls_per_day,
|
||||
course.weight_factor, n_sims, rng)
|
||||
))
|
||||
|
||||
# Benchmark: focal always present, everyone else at real attendance.
|
||||
probe = present.copy()
|
||||
probe[0, :] = True
|
||||
e_full_probe = float(np.mean(
|
||||
_simulate_counts(probe, always, calls_per_day,
|
||||
course.weight_factor, n_sims, rng)
|
||||
))
|
||||
|
||||
# Foregone curve: expected shortfall when the focal student misses
|
||||
# k random days, averaged over placements and draws.
|
||||
max_k = max(8, max(days_missed, default=0))
|
||||
max_k = min(max_k, n_days)
|
||||
curve = [0.0]
|
||||
for k in range(1, max_k + 1):
|
||||
focal_present = np.ones((n_sims, n_days), dtype=bool)
|
||||
for row in range(n_sims):
|
||||
focal_present[row, rng.choice(n_days, size=k, replace=False)] = False
|
||||
counts = _simulate_counts(probe, focal_present, calls_per_day,
|
||||
course.weight_factor, n_sims, rng)
|
||||
curve.append(round(max(0.0, e_full_probe - float(np.mean(counts))), 4))
|
||||
|
||||
severity = (100.0 - course.passing_points) / sd_full
|
||||
allowance_q = course.allowance_sd_units * sd_full
|
||||
|
||||
students = []
|
||||
for i, s in enumerate(enrolled):
|
||||
answered = inputs["points"].get(s.id, [])
|
||||
q_pts = (sum(answered) / len(answered)) if answered else None
|
||||
foregone = curve[min(days_missed[i], max_k)]
|
||||
caught = len(
|
||||
inputs["missing_days"].get(s.id, set())
|
||||
- inputs["optout_days"].get(s.id, set())
|
||||
)
|
||||
if q_pts is None:
|
||||
final = 0.0
|
||||
else:
|
||||
deduction = max(0.0, severity * (foregone - allowance_q))
|
||||
final = max(
|
||||
0.0, q_pts - deduction - course.form_penalty_points * caught
|
||||
)
|
||||
students.append(
|
||||
{
|
||||
"student_id": s.id,
|
||||
"name": s.name,
|
||||
"asked": len(answered),
|
||||
"days_missed": int(days_missed[i]),
|
||||
"q_points": round(q_pts, 2) if q_pts is not None else None,
|
||||
"foregone": round(foregone, 2),
|
||||
"caught": caught,
|
||||
"final_points": round(final, 2),
|
||||
}
|
||||
)
|
||||
students.sort(key=lambda r: (r["days_missed"], -(r["q_points"] or -1)))
|
||||
|
||||
return {
|
||||
"params": params,
|
||||
"n_days": n_days,
|
||||
"n_students": n,
|
||||
"excluded_students": inputs["excluded_students"],
|
||||
"excluded_calls": inputs["excluded_calls"],
|
||||
"e_full": round(total_answered / n, 2),
|
||||
"e_full_probe": round(e_full_probe, 2),
|
||||
"sd_full": round(sd_full, 4),
|
||||
"severity": round(severity, 4),
|
||||
"allowance_q": round(allowance_q, 4),
|
||||
"curve": curve,
|
||||
"students": students,
|
||||
}
|
||||
|
||||
|
||||
def run_and_store(db, course):
|
||||
results = compute(db, course)
|
||||
run = GradeRun(
|
||||
course_id=course.id,
|
||||
params=json.dumps(results.get("params", {})),
|
||||
results=json.dumps(results),
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
return run
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
@@ -48,6 +49,15 @@ def _parse_date(value):
|
||||
abort(400, "Bad date; expected YYYY-MM-DD.")
|
||||
|
||||
|
||||
def _parse_time(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.time.fromisoformat(value)
|
||||
except ValueError:
|
||||
abort(400, "Bad time; expected HH:MM.")
|
||||
|
||||
|
||||
def _get_course_call(db, call_id):
|
||||
call = db.get(Call, call_id)
|
||||
if call is None or call.course_id != current_course().id:
|
||||
@@ -338,13 +348,16 @@ def export_optouts():
|
||||
.where(OptOut.course_id == course.id)
|
||||
.order_by(OptOut.date, Student.sortable_name)
|
||||
).all()
|
||||
# Withdrawn opt-outs are included: the export is the audit trail.
|
||||
return _csv_response(
|
||||
"optouts.csv",
|
||||
["date", "name", "email", "canvas_user_id", "submitted_at"],
|
||||
["date", "name", "email", "canvas_user_id", "submitted_at",
|
||||
"withdrawn_at"],
|
||||
[
|
||||
[
|
||||
optout.date.isoformat(), student.name, student.email,
|
||||
student.canvas_user_id, optout.created_at.isoformat(),
|
||||
optout.withdrawn_at.isoformat() if optout.withdrawn_at else "",
|
||||
]
|
||||
for optout, student in rows
|
||||
],
|
||||
@@ -380,11 +393,16 @@ def schedule_generate():
|
||||
if (end - start).days > 366:
|
||||
abort(400, "Range longer than a year; check the dates.")
|
||||
|
||||
start_time = _parse_time(request.form.get("start_time"))
|
||||
existing = {d.date for d in class_days(db, course.id)}
|
||||
day = start
|
||||
while day <= end:
|
||||
if day.weekday() in chosen and day not in existing:
|
||||
db.add(ClassDay(course_id=course.id, date=day))
|
||||
db.add(
|
||||
ClassDay(
|
||||
course_id=course.id, date=day, start_time=start_time
|
||||
)
|
||||
)
|
||||
day += datetime.timedelta(days=1)
|
||||
db.commit()
|
||||
return redirect(url_for(".schedule"))
|
||||
@@ -397,7 +415,13 @@ def schedule_add():
|
||||
course = current_course()
|
||||
day = _parse_date(request.form.get("date"))
|
||||
if not any(d.date == day for d in class_days(db, course.id)):
|
||||
db.add(ClassDay(course_id=course.id, date=day))
|
||||
db.add(
|
||||
ClassDay(
|
||||
course_id=course.id,
|
||||
date=day,
|
||||
start_time=_parse_time(request.form.get("start_time")),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for(".schedule"))
|
||||
|
||||
@@ -506,5 +530,57 @@ def settings_save():
|
||||
position=max((l.position for l in existing), default=-1) + 1,
|
||||
)
|
||||
)
|
||||
|
||||
# Grading parameters.
|
||||
for field, low, high in [
|
||||
("allowance_sd_units", 0, 10),
|
||||
("passing_points", 0, 100),
|
||||
("form_penalty_points", 0, 100),
|
||||
]:
|
||||
value = request.form.get(field, type=float)
|
||||
if value is not None and low <= value <= high:
|
||||
setattr(course, field, value)
|
||||
n_sims = request.form.get("n_sims", type=int)
|
||||
if n_sims and 100 <= n_sims <= 100000:
|
||||
course.n_sims = n_sims
|
||||
seed = request.form.get("sim_seed", type=int)
|
||||
if seed is not None:
|
||||
course.sim_seed = seed
|
||||
|
||||
# Display scale.
|
||||
scale_type = request.form.get("scale_type")
|
||||
if scale_type in ("uw4", "table", "none"):
|
||||
course.scale_type = scale_type
|
||||
scale_rows = request.form.get("scale_rows")
|
||||
if scale_rows is not None:
|
||||
rows = []
|
||||
for line in scale_rows.splitlines():
|
||||
parts = line.strip().split(None, 1)
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
rows.append([float(parts[0]), parts[1].strip()])
|
||||
except ValueError:
|
||||
pass
|
||||
course.scale_config = (
|
||||
json.dumps(sorted(rows, key=lambda r: -r[0])) if rows else None
|
||||
)
|
||||
|
||||
course.publish_grade_reports = bool(
|
||||
request.form.get("publish_grade_reports")
|
||||
)
|
||||
course.ags_enabled = bool(request.form.get("ags_enabled"))
|
||||
db.commit()
|
||||
return redirect(url_for(".settings"))
|
||||
|
||||
|
||||
@bp.post("/settings/import-scale")
|
||||
@require_instructor
|
||||
def settings_import_scale():
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
if not course.canvas_grading_scheme:
|
||||
abort(400, "Canvas has not provided a grading scheme for this course.")
|
||||
course.scale_config = course.canvas_grading_scheme
|
||||
course.scale_type = "table"
|
||||
db.commit()
|
||||
return redirect(url_for(".settings"))
|
||||
|
||||
@@ -26,6 +26,28 @@ def clean_custom_value(value):
|
||||
return value
|
||||
|
||||
|
||||
def parse_grading_scheme(value):
|
||||
"""Canvas's $com.instructure.Course.gradingScheme expansion: a JSON
|
||||
array of {name, value} grade levels. Normalized to our threshold
|
||||
rows [[min_points, label], ...], highest first."""
|
||||
import json
|
||||
|
||||
value = clean_custom_value(value)
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
rows = [
|
||||
[float(level["value"]), str(level["name"])] for level in value
|
||||
]
|
||||
except (ValueError, TypeError, KeyError):
|
||||
return None
|
||||
if not rows:
|
||||
return None
|
||||
return json.dumps(sorted(rows, key=lambda r: -r[0]))
|
||||
|
||||
|
||||
def parse_custom_date(value):
|
||||
"""Canvas date expansions arrive as ISO-ish timestamps; keep the
|
||||
date part and tolerate absence or junk."""
|
||||
@@ -54,6 +76,7 @@ class LaunchInfo:
|
||||
resource_link_id: str | None
|
||||
course_start: datetime.date | None
|
||||
course_end: datetime.date | None
|
||||
grading_scheme: str | None
|
||||
is_instructor: bool
|
||||
|
||||
|
||||
@@ -76,6 +99,7 @@ def extract_launch_info(launch_data):
|
||||
resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"),
|
||||
course_start=parse_custom_date(custom.get("course_start")),
|
||||
course_end=parse_custom_date(custom.get("course_end")),
|
||||
grading_scheme=parse_grading_scheme(custom.get("grading_scheme")),
|
||||
is_instructor=roles_include_instructor(launch_data.get(CLAIM_ROLES, [])),
|
||||
)
|
||||
|
||||
@@ -98,6 +122,8 @@ def upsert_launch(db, info):
|
||||
course.start_date = info.course_start
|
||||
if info.course_end:
|
||||
course.end_date = info.course_end
|
||||
if info.grading_scheme:
|
||||
course.canvas_grading_scheme = info.grading_scheme
|
||||
|
||||
student = db.execute(
|
||||
select(Student).where(Student.canvas_user_id == info.user_id)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
from flask import Blueprint, abort, current_app, jsonify, redirect, session, url_for
|
||||
from pylti1p3.contrib.flask import (
|
||||
FlaskCookieService,
|
||||
@@ -18,6 +20,28 @@ from .roster import sync_roster
|
||||
bp = Blueprint("lti", __name__, url_prefix="/lti")
|
||||
|
||||
NRPS_CLAIM = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice"
|
||||
AGS_CLAIM = "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"
|
||||
|
||||
|
||||
def ags_service_for_course(course):
|
||||
"""An AssignmentsGradesService usable outside a launch, or None if
|
||||
the course hasn't recorded AGS details."""
|
||||
from pylti1p3.assignments_grades import AssignmentsGradesService
|
||||
|
||||
if not (course.ags_lineitems_url and course.lti_issuer):
|
||||
return None
|
||||
registration = get_tool_conf().find_registration_by_params(
|
||||
course.lti_issuer, client_id=course.lti_client_id
|
||||
)
|
||||
if registration is None:
|
||||
return None
|
||||
return AssignmentsGradesService(
|
||||
ServiceConnector(registration),
|
||||
{
|
||||
"lineitems": course.ags_lineitems_url,
|
||||
"scope": json.loads(course.ags_scopes or "[]"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def nrps_members_for_course(course):
|
||||
@@ -81,6 +105,10 @@ def launch():
|
||||
nrps_data = launch_data.get(NRPS_CLAIM, {})
|
||||
if nrps_data.get("context_memberships_url"):
|
||||
course.nrps_url = nrps_data["context_memberships_url"]
|
||||
ags_data = launch_data.get(AGS_CLAIM, {})
|
||||
if ags_data.get("lineitems"):
|
||||
course.ags_lineitems_url = ags_data["lineitems"]
|
||||
course.ags_scopes = json.dumps(ags_data.get("scope", []))
|
||||
|
||||
# Instructor launches refresh the roster, so adds and drops are
|
||||
# picked up before every class without a separate sync step.
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import (
|
||||
ForeignKey,
|
||||
String,
|
||||
Text,
|
||||
Time,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
select,
|
||||
@@ -74,6 +75,33 @@ class Course(Base):
|
||||
)
|
||||
show_assessments: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Grading parameters (participation_grades.R defaults, expressed in
|
||||
# points out of 100; 65.15 pts and 4.55 pts are the UW-4.0 values
|
||||
# 1.7 and 0.3 under the linear mapping).
|
||||
allowance_sd_units: Mapped[float] = mapped_column(Float, default=0.25)
|
||||
passing_points: Mapped[float] = mapped_column(Float, default=65.15)
|
||||
form_penalty_points: Mapped[float] = mapped_column(Float, default=4.55)
|
||||
n_sims: Mapped[int] = mapped_column(default=4000)
|
||||
sim_seed: Mapped[int] = mapped_column(default=20260606)
|
||||
|
||||
# Display scale for grades: "uw4" (linear UW 4.0 map), "table"
|
||||
# (threshold rows in scale_config JSON: [[min_points, label], ...]),
|
||||
# or "none" (points only).
|
||||
scale_type: Mapped[str] = mapped_column(String(16), default="uw4")
|
||||
scale_config: Mapped[str | None] = mapped_column(Text)
|
||||
# The course's grading scheme as Canvas reports it at launch
|
||||
# (JSON [[min_points, label], ...]); import copies it into
|
||||
# scale_config.
|
||||
canvas_grading_scheme: Mapped[str | None] = mapped_column(Text)
|
||||
publish_grade_reports: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Gradebook passback: off by default; the push pages only exist
|
||||
# when enabled. Endpoint details are captured at launch regardless,
|
||||
# so enabling later needs no re-launch.
|
||||
ags_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
ags_lineitems_url: Mapped[str | None] = mapped_column(String(1024))
|
||||
ags_scopes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=utcnow
|
||||
)
|
||||
@@ -166,6 +194,9 @@ class ClassDay(Base):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
||||
date: Mapped[datetime.date] = mapped_column(Date)
|
||||
# When class begins; bounds opt-out withdrawal. Optional: with no
|
||||
# time recorded, same-day withdrawal is simply not allowed.
|
||||
start_time: Mapped[datetime.time | None] = mapped_column(Time)
|
||||
|
||||
|
||||
class OptOut(Base):
|
||||
@@ -179,6 +210,10 @@ class OptOut(Base):
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=utcnow
|
||||
)
|
||||
# Withdrawals are recorded, never erased: an opt-out with
|
||||
# withdrawn_at set no longer counts anywhere, but the audit trail
|
||||
# of the change survives.
|
||||
withdrawn_at: Mapped[datetime.datetime | None] = mapped_column(DateTime)
|
||||
|
||||
|
||||
class Call(Base):
|
||||
@@ -205,6 +240,30 @@ class Call(Base):
|
||||
return self.level.label if self.level else None
|
||||
|
||||
|
||||
class GradeRun(Base):
|
||||
"""One computed grading result: the parameter snapshot and the full
|
||||
per-student output, stored as JSON. Grade pages always show a run,
|
||||
never a live computation, so what you reviewed is what you push."""
|
||||
|
||||
__tablename__ = "grade_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=utcnow
|
||||
)
|
||||
params: Mapped[str] = mapped_column(Text)
|
||||
results: Mapped[str] = mapped_column(Text)
|
||||
|
||||
|
||||
def latest_grade_run(session, course_id):
|
||||
return session.execute(
|
||||
select(GradeRun)
|
||||
.where(GradeRun.course_id == course_id)
|
||||
.order_by(GradeRun.created_at.desc(), GradeRun.id.desc())
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def answered_call_counts(session, course_id):
|
||||
"""Number of answered calls per student, the input to selection
|
||||
weighting. Skipped, pending, and missing calls do not count, matching
|
||||
@@ -242,10 +301,31 @@ def is_class_day(session, course_id, on_date):
|
||||
)
|
||||
|
||||
|
||||
def class_has_begun(session, course_id, day, now=None):
|
||||
"""Whether the class on `day` has started, for bounding opt-out
|
||||
withdrawal. Without a recorded start time, the whole class day
|
||||
counts as begun (the conservative reading)."""
|
||||
now = now or datetime.datetime.now()
|
||||
if day < now.date():
|
||||
return True
|
||||
if day > now.date():
|
||||
return False
|
||||
row = session.execute(
|
||||
select(ClassDay).where(
|
||||
ClassDay.course_id == course_id, ClassDay.date == day
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None or row.start_time is None:
|
||||
return True
|
||||
return now.time() >= row.start_time
|
||||
|
||||
|
||||
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(
|
||||
OptOut.course_id == course_id, OptOut.date == on_date
|
||||
OptOut.course_id == course_id,
|
||||
OptOut.date == on_date,
|
||||
OptOut.withdrawn_at.is_(None),
|
||||
)
|
||||
rows = session.execute(
|
||||
select(Student)
|
||||
|
||||
@@ -66,7 +66,9 @@ def student_stats(db, course):
|
||||
|
||||
optout_dates = defaultdict(set)
|
||||
for optout in db.execute(
|
||||
select(OptOut).where(OptOut.course_id == course.id)
|
||||
select(OptOut).where(
|
||||
OptOut.course_id == course.id, OptOut.withdrawn_at.is_(None)
|
||||
)
|
||||
).scalars():
|
||||
optout_dates[optout.student_id].add(optout.date)
|
||||
|
||||
|
||||
42
coldcall_lti/scales.py
Normal file
42
coldcall_lti/scales.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Display scales: grades are computed in points out of 100; a
|
||||
per-course scale maps points to what humans read. "uw4" is the linear
|
||||
UW map from the old R scripts (100 -> 4.0, one grade step per
|
||||
15.15 points, clamped to [0, 4]); "table" maps through threshold rows
|
||||
[[min_points, label], ...] (letter grades); "none" shows points."""
|
||||
|
||||
import json
|
||||
|
||||
GPA_POINT_VALUE = 50 / (4 - 0.7)
|
||||
|
||||
LETTER_TABLE = [
|
||||
[93, "A"], [90, "A-"], [87, "B+"], [83, "B"], [80, "B-"],
|
||||
[77, "C+"], [73, "C"], [70, "C-"], [67, "D+"], [63, "D"],
|
||||
[60, "D-"], [0, "F"],
|
||||
]
|
||||
|
||||
|
||||
def to_uw4(points):
|
||||
value = points / GPA_POINT_VALUE - (100 / GPA_POINT_VALUE - 4)
|
||||
return round(min(4.0, max(0.0, value)), 2)
|
||||
|
||||
|
||||
def display(course, points):
|
||||
if points is None:
|
||||
return ""
|
||||
if course.scale_type == "uw4":
|
||||
return f"{to_uw4(points):.2f}"
|
||||
if course.scale_type == "table":
|
||||
rows = json.loads(course.scale_config or "[]")
|
||||
for min_points, label in sorted(rows, key=lambda r: -r[0]):
|
||||
if points >= min_points:
|
||||
return str(label)
|
||||
return str(rows[-1][1]) if rows else f"{points:.1f}"
|
||||
return f"{points:.1f}"
|
||||
|
||||
|
||||
def ags_score(course, points):
|
||||
"""What to push to the gradebook: the numeric display value when
|
||||
the scale is numeric, raw points otherwise."""
|
||||
if course.scale_type == "uw4":
|
||||
return to_uw4(points), 4.0
|
||||
return round(points, 2), 100.0
|
||||
53
coldcall_lti/templates/grade_report.html
Normal file
53
coldcall_lti/templates/grade_report.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Participation report — {{ student.name }}{% endblock %}
|
||||
{% block body %}
|
||||
{% if is_instructor_view %}
|
||||
<p class="no-print"><a href="{{ url_for('grades.view') }}">← back to grades</a></p>
|
||||
{% endif %}
|
||||
<h1>Case discussion participation report</h1>
|
||||
<p><strong>{{ student.name }}</strong></p>
|
||||
<p>Participation grade: <strong>{{ final_display }}</strong></p>
|
||||
|
||||
<h2>When you were called, and how it was assessed</h2>
|
||||
<table>
|
||||
<tr><th>Date</th><th>Answered</th><th>Assessment</th></tr>
|
||||
{% for c in calls %}
|
||||
<tr>
|
||||
<td>{{ c.session_date.isoformat() }}</td>
|
||||
<td>{{ "yes" if c.status == "answered" else "no (not present)" }}</td>
|
||||
<td>{{ c.assessment or "—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p>You answered <strong>{{ row.asked }}</strong> of the
|
||||
<strong>{{ calls | length }}</strong> times your name was drawn.</p>
|
||||
|
||||
<h2>How your grade was computed</h2>
|
||||
<ul>
|
||||
<li><strong>Quality of your answers:</strong> {{ q_display }}
|
||||
({{ row.q_points }} points), the average of the assessments above.</li>
|
||||
<li><strong>Availability:</strong> you were available for
|
||||
{{ results.n_days - row.days_missed }} of {{ results.n_days }}
|
||||
class sessions (missed {{ row.days_missed }}).</li>
|
||||
<li><strong>Participation missed:</strong> about
|
||||
<strong>{{ row.foregone }} questions'</strong> worth, after
|
||||
accounting for the cold-call program calling on you more when you
|
||||
return from an absence. A generous allowance (about
|
||||
{{ "%.2f" | format(results.allowance_q) }} questions) is applied
|
||||
before any shortfall affects your grade.</li>
|
||||
{% if row.caught > 0 %}
|
||||
<li><strong>Opt-out form:</strong> {{ row.caught }} time(s) your name
|
||||
was drawn while you were away without an opt-out on file
|
||||
(−{{ results.params.form_penalty_points }} points each).</li>
|
||||
{% else %}
|
||||
<li><strong>Opt-out form:</strong> no deductions.</li>
|
||||
{% endif %}
|
||||
<li><strong>Final participation grade:</strong>
|
||||
<strong>{{ final_display }}</strong>.</li>
|
||||
</ul>
|
||||
|
||||
<p>Your grade depends only on the quality of your answers and on
|
||||
participation you missed by being unavailable. You are never penalized
|
||||
for how often the program happened to call on you, and the reason for
|
||||
any absence never enters the calculation.</p>
|
||||
{% endblock %}
|
||||
72
coldcall_lti/templates/grades.html
Normal file
72
coldcall_lti/templates/grades.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Grades — {{ course.title or "Cold Call" }}{% endblock %}
|
||||
{% block body %}
|
||||
<p class="no-print"><a href="{{ url_for('instructor.home') }}">← back</a> ·
|
||||
<a href="{{ url_for('instructor.settings') }}">grading settings</a></p>
|
||||
<h1>Participation grades</h1>
|
||||
|
||||
<form method="post" action="{{ url_for('grades.compute') }}" class="no-print">
|
||||
<button type="submit">{{ "Recompute" if run else "Compute grades" }}</button>
|
||||
{% if run %}
|
||||
<span class="muted">last computed {{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC</span>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
{% if not results %}
|
||||
<p class="muted">No grades computed yet. Computing runs the simulation
|
||||
with the current settings; nothing is shown to students or sent
|
||||
anywhere until you choose to.</p>
|
||||
{% elif results.error %}
|
||||
<p>{{ results.error }}</p>
|
||||
{% else %}
|
||||
<h2>Parameters</h2>
|
||||
<table>
|
||||
<tr><td>Class days</td><td>{{ results.n_days }}</td></tr>
|
||||
<tr><td>Enrolled students</td><td>{{ results.n_students }}</td></tr>
|
||||
<tr><td>Equal share of questions</td><td>{{ results.e_full }}</td></tr>
|
||||
<tr><td>SD of full-attendance counts</td><td>{{ "%.2f" | format(results.sd_full) }}</td></tr>
|
||||
<tr><td>Severity (points per question)</td><td>{{ "%.2f" | format(results.severity) }}</td></tr>
|
||||
<tr><td>Allowance (questions)</td><td>{{ "%.2f" | format(results.allowance_q) }}
|
||||
<span class="muted">= {{ results.params.allowance_sd_units }} SD</span></td></tr>
|
||||
</table>
|
||||
{% if results.excluded_students %}
|
||||
<p class="muted">{{ results.excluded_calls }} call(s) involving
|
||||
{{ results.excluded_students }} dropped (inactive) student(s) are
|
||||
excluded from all of the above.</p>
|
||||
{% endif %}
|
||||
<p class="muted">Foregone-participation curve by days missed:
|
||||
{% for v in results.curve %}{{ loop.index0 }}d: {{ "%.2f" | format(v) }}{{ " · " if not loop.last }}{% endfor %}
|
||||
</p>
|
||||
|
||||
<h2>Grades</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Student</th><th>Asked</th><th>Days missed</th><th>Quality</th>
|
||||
<th>Foregone</th><th>Caught</th><th>Final (pts)</th><th>Grade</th><th></th>
|
||||
</tr>
|
||||
{% for r in results.students %}
|
||||
<tr>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.asked }}</td>
|
||||
<td>{{ r.days_missed or "" }}</td>
|
||||
<td>{{ r.q_points if r.q_points is not none else "—" }}</td>
|
||||
<td>{{ r.foregone or "" }}</td>
|
||||
<td>{{ r.caught or "" }}</td>
|
||||
<td>{{ r.final_points }}</td>
|
||||
<td><strong>{{ display(r.final_points) }}</strong></td>
|
||||
<td class="no-print">
|
||||
<a href="{{ url_for('grades.student_report', student_id=r.student_id) }}">report</a>
|
||||
{% if r.q_points is none %}<span class="muted">never answered</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<p class="no-print">
|
||||
<a href="{{ url_for('grades.export') }}">Export grades.csv</a>
|
||||
{% if course.ags_enabled %}
|
||||
· <a href="{{ url_for('grades.push_preview') }}">Review & push to gradebook</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="{{ url_for('instructor.live') }}">Live cold call</a> ·
|
||||
<a href="{{ url_for('instructor.day_edit', date_str=today.isoformat()) }}">Today's calls</a> ·
|
||||
<a href="{{ url_for('instructor.report') }}">Report</a> ·
|
||||
<a href="{{ url_for('grades.view') }}">Grades</a> ·
|
||||
<a href="{{ url_for('instructor.schedule') }}">Schedule</a> ·
|
||||
<a href="{{ url_for('instructor.settings') }}">Settings</a>
|
||||
</p>
|
||||
|
||||
23
coldcall_lti/templates/push_preview.html
Normal file
23
coldcall_lti/templates/push_preview.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Push to gradebook — {{ course.title or "Cold Call" }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="{{ url_for('grades.view') }}">← back to grades</a></p>
|
||||
<h1>Review before pushing to the gradebook</h1>
|
||||
<p>This will write the scores below to a "Case discussion
|
||||
participation" column in the Canvas gradebook (computed
|
||||
{{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC). Nothing is
|
||||
sent until you confirm at the bottom.</p>
|
||||
<table>
|
||||
<tr><th>Student</th><th>Score to send</th><th>Out of</th></tr>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r.row.name }}</td>
|
||||
<td><strong>{{ r.score }}</strong></td>
|
||||
<td>{{ r.maximum }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<form method="post" action="{{ url_for('grades.push') }}">
|
||||
<button type="submit" class="big">Push {{ rows | length }} grades to Canvas</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
17
coldcall_lti/templates/push_result.html
Normal file
17
coldcall_lti/templates/push_result.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Push results — {{ course.title or "Cold Call" }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="{{ url_for('grades.view') }}">← back to grades</a></p>
|
||||
<h1>Gradebook push results</h1>
|
||||
<table>
|
||||
<tr><th>Student</th><th>Result</th></tr>
|
||||
{% for o in outcomes %}
|
||||
<tr>
|
||||
<td>{{ o.name }}</td>
|
||||
<td>{{ "✓" if o.ok else "FAILED:" }} {{ o.detail }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p>{{ outcomes | selectattr("ok") | list | length }} of
|
||||
{{ outcomes | length }} pushed successfully.</p>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,9 @@
|
||||
<h1>Class schedule</h1>
|
||||
<p class="muted">When class days are listed here, the student opt-out
|
||||
form only offers these dates. With no days listed, students can pick
|
||||
any date.</p>
|
||||
any date. Start times bound opt-out withdrawal: students can withdraw
|
||||
an opt-out until class begins; with no time recorded, withdrawal
|
||||
closes at the start of the class day.</p>
|
||||
|
||||
{% set dmin = course.start_date.isoformat() if course.start_date else "" %}
|
||||
{% set dmax = course.end_date.isoformat() if course.end_date else "" %}
|
||||
@@ -15,6 +17,7 @@
|
||||
value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||
<label>to <input type="date" name="end" required
|
||||
value="{{ dmax }}" min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||
<label>starting at <input type="time" name="start_time"></label>
|
||||
{% for i, name in weekdays %}
|
||||
<label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label>
|
||||
{% endfor %}
|
||||
@@ -25,6 +28,7 @@
|
||||
<form method="post" action="{{ url_for('instructor.schedule_add') }}">
|
||||
<label>Date <input type="date" name="date" required
|
||||
min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||
<label>starting at <input type="time" name="start_time"></label>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
|
||||
@@ -34,7 +38,9 @@
|
||||
{% for d in days %}
|
||||
<tr>
|
||||
<td {% if d.date < today %}class="muted"{% endif %}>
|
||||
{{ d.date.strftime("%a %b %-d, %Y") }}</td>
|
||||
{{ d.date.strftime("%a %b %-d, %Y") }}
|
||||
{% if d.start_time %}<span class="muted">{{ d.start_time.strftime("%H:%M") }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post"
|
||||
action="{{ url_for('instructor.schedule_delete', day_id=d.id) }}">
|
||||
|
||||
@@ -63,6 +63,81 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>Grading</h2>
|
||||
<p class="muted">Grades use the timing-neutral foregone-participation
|
||||
scheme: answer quality minus a deduction for participation missed
|
||||
through unavailability, estimated by simulating the cold-call draw.
|
||||
All values are in points out of 100.</p>
|
||||
<p>
|
||||
<label>Allowance (SD units):
|
||||
<input type="number" name="allowance_sd_units" step="0.05" min="0"
|
||||
value="{{ course.allowance_sd_units }}"></label>
|
||||
<span class="muted">Foregone participation forgiven before any
|
||||
deduction, in units of the draw's natural spread.</span>
|
||||
</p>
|
||||
<p>
|
||||
<label>Passing line (points):
|
||||
<input type="number" name="passing_points" step="0.01" min="0" max="100"
|
||||
value="{{ course.passing_points }}"></label>
|
||||
<span class="muted">Forgoing one SD of participation lands a
|
||||
perfect-quality student here (65.15 = UW 1.7).</span>
|
||||
</p>
|
||||
<p>
|
||||
<label>Form-filing penalty (points):
|
||||
<input type="number" name="form_penalty_points" step="0.01" min="0" max="100"
|
||||
value="{{ course.form_penalty_points }}"></label>
|
||||
<span class="muted">Per day called while absent with no opt-out
|
||||
filed (4.55 = UW 0.3).</span>
|
||||
</p>
|
||||
<p>
|
||||
<label>Simulation iterations:
|
||||
<input type="number" name="n_sims" min="100" max="100000"
|
||||
value="{{ course.n_sims }}"></label>
|
||||
<label>Seed:
|
||||
<input type="number" name="sim_seed" value="{{ course.sim_seed }}"></label>
|
||||
</p>
|
||||
|
||||
<h2>Grade display scale</h2>
|
||||
<p>
|
||||
<label>Scale:
|
||||
<select name="scale_type">
|
||||
<option value="uw4" {{ "selected" if course.scale_type == "uw4" }}>UW 4.0 (linear)</option>
|
||||
<option value="table" {{ "selected" if course.scale_type == "table" }}>threshold table</option>
|
||||
<option value="none" {{ "selected" if course.scale_type == "none" }}>points only</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label>Threshold rows (one per line: minimum points, then label):<br>
|
||||
<textarea name="scale_rows" rows="6" cols="24"
|
||||
>{% if course.scale_config %}{% for min_points, label in course.scale_config | fromjson %}{{ min_points }} {{ label }}
|
||||
{% endfor %}{% endif %}</textarea></label>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label><input type="checkbox" name="publish_grade_reports"
|
||||
{{ "checked" if course.publish_grade_reports }}>
|
||||
Publish grade reports to students (adds a report to each
|
||||
student's page once grades are computed)</label>
|
||||
</p>
|
||||
<p>
|
||||
<label><input type="checkbox" name="ags_enabled"
|
||||
{{ "checked" if course.ags_enabled }}>
|
||||
Enable gradebook passback (adds a review-then-push page; nothing
|
||||
is ever sent without explicit confirmation)</label>
|
||||
</p>
|
||||
|
||||
<p><button type="submit">Save</button></p>
|
||||
</form>
|
||||
|
||||
{% if course.canvas_grading_scheme %}
|
||||
<form method="post" action="{{ url_for('instructor.settings_import_scale') }}">
|
||||
<p>
|
||||
<button type="submit">Use the Canvas grading scheme</button>
|
||||
<span class="muted">Copies this course's grading scheme from Canvas
|
||||
({{ course.canvas_grading_scheme | fromjson | length }} levels)
|
||||
into the threshold table.</span>
|
||||
</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
they are not right, update them in your Canvas account settings and
|
||||
they will be picked up here automatically.</p>
|
||||
|
||||
{% if report_available %}
|
||||
<p><a href="{{ url_for('views.my_grade_report') }}"><strong>Your
|
||||
participation grade report</strong></a></p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Your standing</h2>
|
||||
<p>
|
||||
You have answered <strong>{{ answered }}</strong>
|
||||
@@ -47,22 +52,23 @@
|
||||
{% if optouts %}
|
||||
<table>
|
||||
<tr><th>Date</th><th></th></tr>
|
||||
{% for o in optouts %}
|
||||
{% for o, withdrawable in optouts %}
|
||||
<tr>
|
||||
<td>{{ o.date.isoformat() }}</td>
|
||||
<td>
|
||||
{% if o.date >= today %}
|
||||
{% if withdrawable %}
|
||||
<form method="post"
|
||||
action="{{ url_for('views.optout_delete', optout_id=o.id) }}">
|
||||
<button type="submit">Withdraw</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="muted">past</span>
|
||||
<span class="muted">locked</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p class="muted">Opt-outs can be withdrawn until the class begins.</p>
|
||||
{% else %}
|
||||
<p class="muted">You have not opted out of any classes.</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -16,7 +16,9 @@ from .models import (
|
||||
Student,
|
||||
answered_call_counts,
|
||||
class_days,
|
||||
class_has_begun,
|
||||
is_class_day,
|
||||
latest_grade_run,
|
||||
)
|
||||
|
||||
bp = Blueprint("views", __name__)
|
||||
@@ -119,17 +121,22 @@ def student_home():
|
||||
db.execute(
|
||||
select(OptOut)
|
||||
.where(
|
||||
OptOut.course_id == course.id, OptOut.student_id == user.id
|
||||
OptOut.course_id == course.id,
|
||||
OptOut.student_id == user.id,
|
||||
OptOut.withdrawn_at.is_(None),
|
||||
)
|
||||
.order_by(OptOut.date)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
optouts = [
|
||||
(o, not class_has_begun(db, course.id, o.date)) for o in optouts
|
||||
]
|
||||
upcoming_days = [
|
||||
d.date
|
||||
for d in class_days(db, course.id, start=today)
|
||||
if d.date not in {o.date for o in optouts}
|
||||
if d.date not in {o.date for o, _ in optouts}
|
||||
]
|
||||
|
||||
return render_template(
|
||||
@@ -143,6 +150,34 @@ def student_home():
|
||||
optouts=optouts,
|
||||
upcoming_days=upcoming_days,
|
||||
distribution=_class_call_distribution(db, course, user.id),
|
||||
report_available=(
|
||||
course.publish_grade_reports
|
||||
and latest_grade_run(db, course.id) is not None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/me/report")
|
||||
@require_launch
|
||||
def my_grade_report():
|
||||
import json
|
||||
|
||||
from .grades import student_report_context
|
||||
|
||||
db = get_db()
|
||||
course = current_course()
|
||||
user = current_user()
|
||||
if not course.publish_grade_reports:
|
||||
abort(404)
|
||||
run = latest_grade_run(db, course.id)
|
||||
if run is None:
|
||||
abort(404)
|
||||
results = json.loads(run.results)
|
||||
context = student_report_context(db, course, results, user.id)
|
||||
if context is None:
|
||||
abort(404)
|
||||
return render_template(
|
||||
"grade_report.html", is_instructor_view=False, **context
|
||||
)
|
||||
|
||||
|
||||
@@ -172,6 +207,11 @@ def optout_create():
|
||||
if exists is None:
|
||||
db.add(OptOut(course_id=course.id, student_id=user.id, date=day))
|
||||
db.commit()
|
||||
elif exists.withdrawn_at is not None:
|
||||
# Re-opting-out after a withdrawal reuses the row; the
|
||||
# withdrawal timestamp is cleared but creation time remains.
|
||||
exists.withdrawn_at = None
|
||||
db.commit()
|
||||
return redirect(url_for(".student_home"))
|
||||
|
||||
|
||||
@@ -184,10 +224,11 @@ def optout_delete(optout_id):
|
||||
optout is None
|
||||
or optout.course_id != current_course().id
|
||||
or optout.student_id != current_user().id
|
||||
or optout.withdrawn_at is not None
|
||||
):
|
||||
abort(404)
|
||||
if optout.date < datetime.date.today():
|
||||
abort(400, "Past opt-outs cannot be withdrawn.")
|
||||
db.delete(optout)
|
||||
if class_has_begun(db, optout.course_id, optout.date):
|
||||
abort(400, "Opt-outs cannot be withdrawn once class has begun.")
|
||||
optout.withdrawn_at = datetime.datetime.now(datetime.UTC)
|
||||
db.commit()
|
||||
return redirect(url_for(".student_home"))
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""class start times and optout withdrawal audit
|
||||
|
||||
Revision ID: 3b6f28fbdc4b
|
||||
Revises: 826e2835e289
|
||||
Create Date: 2026-07-31 18:35:27.102919
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3b6f28fbdc4b'
|
||||
down_revision: Union[str, None] = '826e2835e289'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('class_days', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('start_time', sa.Time(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('optouts', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('withdrawn_at', sa.DateTime(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('optouts', schema=None) as batch_op:
|
||||
batch_op.drop_column('withdrawn_at')
|
||||
|
||||
with op.batch_alter_table('class_days', schema=None) as batch_op:
|
||||
batch_op.drop_column('start_time')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,78 +0,0 @@
|
||||
"""assessment levels and nrps details
|
||||
|
||||
Revision ID: 826e2835e289
|
||||
Revises: 487d155678ea
|
||||
Create Date: 2026-07-31 16:56:00.716357
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '826e2835e289'
|
||||
down_revision: Union[str, None] = '487d155678ea'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('assessment_levels', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_assessment_levels_course_id'), ['course_id', 'label'])
|
||||
|
||||
with op.batch_alter_table('calls', schema=None) as batch_op:
|
||||
batch_op.create_foreign_key(batch_op.f('fk_calls_assessment_id_assessment_levels'), 'assessment_levels', ['assessment_id'], ['id'])
|
||||
batch_op.drop_column('assessment')
|
||||
|
||||
with op.batch_alter_table('class_days', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_class_days_course_id'), ['course_id', 'date'])
|
||||
|
||||
with op.batch_alter_table('courses', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('lti_issuer', sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column('lti_client_id', sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column('nrps_url', sa.String(length=1024), nullable=True))
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_courses_lti_context_id'), ['lti_context_id'])
|
||||
|
||||
with op.batch_alter_table('enrollments', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_enrollments_course_id'), ['course_id', 'student_id'])
|
||||
|
||||
with op.batch_alter_table('optouts', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_optouts_course_id'), ['course_id', 'student_id', 'date'])
|
||||
|
||||
with op.batch_alter_table('students', schema=None) as batch_op:
|
||||
batch_op.create_unique_constraint(batch_op.f('uq_students_canvas_user_id'), ['canvas_user_id'])
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('students', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_students_canvas_user_id'), type_='unique')
|
||||
|
||||
with op.batch_alter_table('optouts', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_optouts_course_id'), type_='unique')
|
||||
|
||||
with op.batch_alter_table('enrollments', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_enrollments_course_id'), type_='unique')
|
||||
|
||||
with op.batch_alter_table('courses', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_courses_lti_context_id'), type_='unique')
|
||||
batch_op.drop_column('nrps_url')
|
||||
batch_op.drop_column('lti_client_id')
|
||||
batch_op.drop_column('lti_issuer')
|
||||
|
||||
with op.batch_alter_table('class_days', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_class_days_course_id'), type_='unique')
|
||||
|
||||
with op.batch_alter_table('calls', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('assessment', sa.VARCHAR(length=32), nullable=True))
|
||||
batch_op.drop_constraint(batch_op.f('fk_calls_assessment_id_assessment_levels'), type_='foreignkey')
|
||||
|
||||
with op.batch_alter_table('assessment_levels', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('uq_assessment_levels_course_id'), type_='unique')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,93 @@
|
||||
"""assessment levels, grading, and platform service details
|
||||
|
||||
Revision ID: 826e2835e289
|
||||
Revises: 487d155678ea
|
||||
Create Date: 2026-07-31 18:21:06.340614
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '826e2835e289'
|
||||
down_revision: Union[str, None] = '487d155678ea'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('assessment_levels',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||
sa.Column('label', sa.String(length=64), nullable=False),
|
||||
sa.Column('points', sa.Float(), nullable=False),
|
||||
sa.Column('position', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], name=op.f('fk_assessment_levels_course_id_courses')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_assessment_levels')),
|
||||
sa.UniqueConstraint('course_id', 'label', name=op.f('uq_assessment_levels_course_id'))
|
||||
)
|
||||
op.create_table('grade_runs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('course_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('params', sa.Text(), nullable=False),
|
||||
sa.Column('results', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], name=op.f('fk_grade_runs_course_id_courses')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_grade_runs'))
|
||||
)
|
||||
with op.batch_alter_table('calls', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('assessment_id', sa.Integer(), nullable=True))
|
||||
batch_op.create_foreign_key(batch_op.f('fk_calls_assessment_id_assessment_levels'), 'assessment_levels', ['assessment_id'], ['id'])
|
||||
batch_op.drop_column('assessment')
|
||||
|
||||
with op.batch_alter_table('courses', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('lti_issuer', sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column('lti_client_id', sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column('nrps_url', sa.String(length=1024), nullable=True))
|
||||
batch_op.add_column(sa.Column('allowance_sd_units', sa.Float(), nullable=False))
|
||||
batch_op.add_column(sa.Column('passing_points', sa.Float(), nullable=False))
|
||||
batch_op.add_column(sa.Column('form_penalty_points', sa.Float(), nullable=False))
|
||||
batch_op.add_column(sa.Column('n_sims', sa.Integer(), nullable=False))
|
||||
batch_op.add_column(sa.Column('sim_seed', sa.Integer(), nullable=False))
|
||||
batch_op.add_column(sa.Column('scale_type', sa.String(length=16), nullable=False))
|
||||
batch_op.add_column(sa.Column('scale_config', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('canvas_grading_scheme', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('publish_grade_reports', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('ags_enabled', sa.Boolean(), nullable=False))
|
||||
batch_op.add_column(sa.Column('ags_lineitems_url', sa.String(length=1024), nullable=True))
|
||||
batch_op.add_column(sa.Column('ags_scopes', sa.Text(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('courses', schema=None) as batch_op:
|
||||
batch_op.drop_column('ags_scopes')
|
||||
batch_op.drop_column('ags_lineitems_url')
|
||||
batch_op.drop_column('ags_enabled')
|
||||
batch_op.drop_column('publish_grade_reports')
|
||||
batch_op.drop_column('canvas_grading_scheme')
|
||||
batch_op.drop_column('scale_config')
|
||||
batch_op.drop_column('scale_type')
|
||||
batch_op.drop_column('sim_seed')
|
||||
batch_op.drop_column('n_sims')
|
||||
batch_op.drop_column('form_penalty_points')
|
||||
batch_op.drop_column('passing_points')
|
||||
batch_op.drop_column('allowance_sd_units')
|
||||
batch_op.drop_column('nrps_url')
|
||||
batch_op.drop_column('lti_client_id')
|
||||
batch_op.drop_column('lti_issuer')
|
||||
|
||||
with op.batch_alter_table('calls', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('assessment', sa.VARCHAR(length=32), nullable=True))
|
||||
batch_op.drop_constraint(batch_op.f('fk_calls_assessment_id_assessment_levels'), type_='foreignkey')
|
||||
batch_op.drop_column('assessment_id')
|
||||
|
||||
op.drop_table('grade_runs')
|
||||
op.drop_table('assessment_levels')
|
||||
# ### end Alembic commands ###
|
||||
123
tests/test_grades_ui.py
Normal file
123
tests/test_grades_ui.py
Normal file
@@ -0,0 +1,123 @@
|
||||
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")
|
||||
# Speed: shrink the simulation for tests.
|
||||
dev_client.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"show_assessments": "on", "n_sims": "200", "sim_seed": "1"},
|
||||
)
|
||||
return dev_client
|
||||
|
||||
|
||||
def record_calls(client, n=6):
|
||||
for _ in range(n):
|
||||
client.post("/instructor/live/next", data={"date": TODAY})
|
||||
page = client.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
|
||||
client.post(
|
||||
f"/instructor/call/{call_id}/outcome",
|
||||
data={"action": outcome_actions(page)["GOOD"]},
|
||||
)
|
||||
|
||||
|
||||
def test_grades_page_before_compute(instructor):
|
||||
page = instructor.get("/instructor/grades/").get_data(as_text=True)
|
||||
assert "No grades computed yet" in page
|
||||
|
||||
|
||||
def test_compute_and_view_grades(instructor):
|
||||
record_calls(instructor)
|
||||
resp = instructor.post(
|
||||
"/instructor/grades/compute", follow_redirects=True
|
||||
)
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "SD of full-attendance counts" in page
|
||||
assert "4.00" in page # someone answered GOOD -> full marks
|
||||
assert "never answered" in page # not everyone got called
|
||||
|
||||
resp = instructor.get("/instructor/grades/export.csv")
|
||||
assert resp.mimetype == "text/csv"
|
||||
assert "final_points" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_student_report_and_publishing(instructor):
|
||||
record_calls(instructor)
|
||||
instructor.post("/instructor/grades/compute")
|
||||
page = instructor.get("/instructor/grades/").get_data(as_text=True)
|
||||
student_id = re.search(r"/instructor/grades/student/(\d+)", page).group(1)
|
||||
report = instructor.get(
|
||||
f"/instructor/grades/student/{student_id}"
|
||||
).get_data(as_text=True)
|
||||
assert "How your grade was computed" in report
|
||||
|
||||
# Unpublished: students see no report link and get 404.
|
||||
instructor.post("/dev/launch/dev-student-1")
|
||||
assert "participation grade report" not in instructor.get("/me").get_data(as_text=True)
|
||||
assert instructor.get("/me/report").status_code == 404
|
||||
|
||||
# Publish, and the student can read their own report.
|
||||
instructor.post("/dev/launch/dev-instructor")
|
||||
instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"publish_grade_reports": "on"},
|
||||
)
|
||||
instructor.post("/dev/launch/dev-student-1")
|
||||
page = instructor.get("/me").get_data(as_text=True)
|
||||
assert "participation grade report" in page
|
||||
report = instructor.get("/me/report").get_data(as_text=True)
|
||||
assert "How your grade was computed" in report
|
||||
assert "Ada Lovelace" in report
|
||||
|
||||
|
||||
def test_push_flow_dev_mode(instructor):
|
||||
record_calls(instructor)
|
||||
instructor.post("/instructor/grades/compute")
|
||||
|
||||
# Push pages don't exist until AGS is enabled.
|
||||
assert instructor.get("/instructor/grades/push").status_code == 404
|
||||
|
||||
instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"ags_enabled": "on"},
|
||||
)
|
||||
page = instructor.get("/instructor/grades/push").get_data(as_text=True)
|
||||
assert "Review before pushing" in page
|
||||
assert "Push 8 grades to Canvas" in page
|
||||
|
||||
resp = instructor.post("/instructor/grades/push")
|
||||
page = resp.get_data(as_text=True)
|
||||
assert "simulated (dev mode)" in page
|
||||
assert "8 of\n 8 pushed successfully" in page or "8 of" in page
|
||||
|
||||
|
||||
def test_grades_require_instructor(dev_client):
|
||||
dev_client.post("/dev/launch/dev-instructor")
|
||||
dev_client.post("/dev/launch/dev-student-1")
|
||||
assert dev_client.get("/instructor/grades/").status_code == 403
|
||||
|
||||
|
||||
def test_scale_settings_and_canvas_import(instructor):
|
||||
# Threshold rows via the settings form.
|
||||
instructor.post(
|
||||
"/instructor/settings",
|
||||
data={"selection_mode": "weighted", "weight_factor": "2",
|
||||
"scale_type": "table",
|
||||
"scale_rows": "93 A\n90 A-\n0 F"},
|
||||
)
|
||||
record_calls(instructor, n=3)
|
||||
instructor.post("/instructor/grades/compute")
|
||||
page = instructor.get("/instructor/grades/").get_data(as_text=True)
|
||||
assert "<strong>A</strong>" in page
|
||||
238
tests/test_grading.py
Normal file
238
tests/test_grading.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from coldcall_lti import grading, models, scales
|
||||
|
||||
D0 = datetime.date(2026, 10, 1)
|
||||
|
||||
|
||||
def setup_course(db, n_students=6, n_days=10, calls_per_day=3,
|
||||
n_sims=400, **course_kw):
|
||||
course = models.Course(
|
||||
lti_context_id="ctx-1", n_sims=n_sims, sim_seed=1, **course_kw
|
||||
)
|
||||
db.add(course)
|
||||
db.flush()
|
||||
models.ensure_default_levels(db, course)
|
||||
levels = {
|
||||
lvl.label: lvl for lvl in models.assessment_levels(db, course.id)
|
||||
}
|
||||
students = []
|
||||
for i in range(n_students):
|
||||
s = models.Student(canvas_user_id=f"u{i}", name=f"Student {i}")
|
||||
db.add(s)
|
||||
students.append(s)
|
||||
db.flush()
|
||||
for s in students:
|
||||
db.add(models.Enrollment(course_id=course.id, student_id=s.id))
|
||||
db.flush()
|
||||
|
||||
# Round-robin answered calls, all GOOD, over n_days class days.
|
||||
days = [D0 + datetime.timedelta(days=i) for i in range(n_days)]
|
||||
idx = 0
|
||||
for day in days:
|
||||
for _ in range(calls_per_day):
|
||||
db.add(
|
||||
models.Call(
|
||||
course_id=course.id,
|
||||
student_id=students[idx % n_students].id,
|
||||
session_date=day,
|
||||
status=models.STATUS_ANSWERED,
|
||||
assessment_id=levels["GOOD"].id,
|
||||
)
|
||||
)
|
||||
idx += 1
|
||||
db.flush()
|
||||
return course, students, days, levels
|
||||
|
||||
|
||||
def test_all_present_all_good_grades_full(db_session):
|
||||
course, students, days, levels = setup_course(db_session)
|
||||
results = grading.compute(db_session, course)
|
||||
assert results["curve"][0] == 0.0
|
||||
for row in results["students"]:
|
||||
assert row["q_points"] == 100.0
|
||||
assert row["days_missed"] == 0
|
||||
assert row["final_points"] == 100.0
|
||||
|
||||
|
||||
def test_foregone_curve_is_nonnegative_and_grows(db_session):
|
||||
course, *_ = setup_course(db_session)
|
||||
results = grading.compute(db_session, course)
|
||||
curve = results["curve"]
|
||||
assert all(v >= 0 for v in curve)
|
||||
# Missing most days must cost more than missing one.
|
||||
assert curve[8] > curve[1]
|
||||
|
||||
|
||||
def test_zero_answer_student_floors_to_zero(db_session):
|
||||
course, students, days, levels = setup_course(db_session)
|
||||
extra = models.Student(canvas_user_id="u-silent", name="Silent Student")
|
||||
db_session.add(extra)
|
||||
db_session.flush()
|
||||
db_session.add(
|
||||
models.Enrollment(course_id=course.id, student_id=extra.id)
|
||||
)
|
||||
db_session.flush()
|
||||
results = grading.compute(db_session, course)
|
||||
row = next(
|
||||
r for r in results["students"] if r["student_id"] == extra.id
|
||||
)
|
||||
assert row["q_points"] is None
|
||||
assert row["final_points"] == 0.0
|
||||
|
||||
|
||||
def test_luck_protection_present_but_rarely_called(db_session):
|
||||
"""A student who is always available but was called only once is
|
||||
not penalized: quality is their grade."""
|
||||
course, students, days, levels = setup_course(db_session)
|
||||
lucky = models.Student(canvas_user_id="u-lucky", name="Lucky Student")
|
||||
db_session.add(lucky)
|
||||
db_session.flush()
|
||||
db_session.add(
|
||||
models.Enrollment(course_id=course.id, student_id=lucky.id)
|
||||
)
|
||||
db_session.add(
|
||||
models.Call(
|
||||
course_id=course.id,
|
||||
student_id=lucky.id,
|
||||
session_date=days[0],
|
||||
status=models.STATUS_ANSWERED,
|
||||
assessment_id=levels["POOR"].id,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
results = grading.compute(db_session, course)
|
||||
row = next(
|
||||
r for r in results["students"] if r["student_id"] == lucky.id
|
||||
)
|
||||
assert row["days_missed"] == 0
|
||||
assert row["final_points"] == pytest.approx(69.7, abs=0.01)
|
||||
|
||||
|
||||
def test_absences_within_allowance_are_free(db_session):
|
||||
course, students, days, levels = setup_course(
|
||||
db_session, allowance_sd_units=5.0
|
||||
)
|
||||
# One student opts out of two days.
|
||||
for day in days[:2]:
|
||||
db_session.add(
|
||||
models.OptOut(
|
||||
course_id=course.id, student_id=students[0].id, date=day
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
results = grading.compute(db_session, course)
|
||||
row = next(
|
||||
r for r in results["students"]
|
||||
if r["student_id"] == students[0].id
|
||||
)
|
||||
assert row["days_missed"] == 2
|
||||
# Huge allowance: no deduction despite the absences.
|
||||
assert row["final_points"] == row["q_points"]
|
||||
|
||||
|
||||
def test_heavy_absence_is_deducted(db_session):
|
||||
course, students, days, levels = setup_course(
|
||||
db_session, allowance_sd_units=0.25
|
||||
)
|
||||
for day in days[:8]:
|
||||
db_session.add(
|
||||
models.OptOut(
|
||||
course_id=course.id, student_id=students[0].id, date=day
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
results = grading.compute(db_session, course)
|
||||
row = next(
|
||||
r for r in results["students"]
|
||||
if r["student_id"] == students[0].id
|
||||
)
|
||||
assert row["final_points"] < row["q_points"]
|
||||
|
||||
|
||||
def test_form_penalty_only_for_uncovered_missing_days(db_session):
|
||||
course, students, days, levels = setup_course(db_session)
|
||||
caught_s, covered_s = students[0], students[1]
|
||||
# Both drawn-but-missing on day 3; only covered_s filed the form.
|
||||
for s in (caught_s, covered_s):
|
||||
db_session.add(
|
||||
models.Call(
|
||||
course_id=course.id,
|
||||
student_id=s.id,
|
||||
session_date=days[3],
|
||||
status=models.STATUS_MISSING,
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
models.OptOut(
|
||||
course_id=course.id, student_id=covered_s.id, date=days[3]
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
results = grading.compute(db_session, course)
|
||||
rows = {r["student_id"]: r for r in results["students"]}
|
||||
assert rows[caught_s.id]["caught"] == 1
|
||||
assert rows[covered_s.id]["caught"] == 0
|
||||
# Both have the same quality and same availability; the caught
|
||||
# student ends exactly one form penalty lower.
|
||||
diff = (rows[covered_s.id]["final_points"]
|
||||
- rows[caught_s.id]["final_points"])
|
||||
assert diff == pytest.approx(course.form_penalty_points, abs=0.01)
|
||||
|
||||
|
||||
def test_dropped_students_are_excluded_everywhere(db_session):
|
||||
course, students, days, levels = setup_course(db_session)
|
||||
baseline = grading.compute(db_session, course)
|
||||
|
||||
dropped = models.Student(canvas_user_id="u-dropped", name="Dropped")
|
||||
db_session.add(dropped)
|
||||
db_session.flush()
|
||||
db_session.add(
|
||||
models.Enrollment(
|
||||
course_id=course.id, student_id=dropped.id, active=False
|
||||
)
|
||||
)
|
||||
for day in days[:3]:
|
||||
db_session.add(
|
||||
models.Call(
|
||||
course_id=course.id,
|
||||
student_id=dropped.id,
|
||||
session_date=day,
|
||||
status=models.STATUS_ANSWERED,
|
||||
assessment_id=levels["GOOD"].id,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
results = grading.compute(db_session, course)
|
||||
assert results["excluded_students"] == 1
|
||||
assert results["excluded_calls"] == 3
|
||||
assert results["n_students"] == baseline["n_students"]
|
||||
assert dropped.id not in {r["student_id"] for r in results["students"]}
|
||||
# Same seed, same effective inputs: identical simulation results.
|
||||
assert results["sd_full"] == baseline["sd_full"]
|
||||
assert results["curve"] == baseline["curve"]
|
||||
|
||||
|
||||
def test_scales():
|
||||
course = models.Course(lti_context_id="x", scale_type="uw4")
|
||||
assert scales.display(course, 100.0) == "4.00"
|
||||
assert scales.display(course, 65.15) == "1.70"
|
||||
assert scales.display(course, 84.85) == "3.00"
|
||||
assert scales.display(course, 0.0) == "0.00"
|
||||
|
||||
course.scale_type = "table"
|
||||
course.scale_config = '[[93, "A"], [90, "A-"], [0, "F"]]'
|
||||
assert scales.display(course, 95) == "A"
|
||||
assert scales.display(course, 91.2) == "A-"
|
||||
assert scales.display(course, 10) == "F"
|
||||
|
||||
course.scale_type = "none"
|
||||
assert scales.display(course, 87.5) == "87.5"
|
||||
|
||||
course.scale_type = "uw4"
|
||||
assert scales.ags_score(course, 84.85) == (3.0, 4.0)
|
||||
course.scale_type = "table"
|
||||
assert scales.ags_score(course, 84.85) == (84.85, 100.0)
|
||||
@@ -88,6 +88,73 @@ def test_students_cannot_touch_others_optouts(student):
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user