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
|
pronouns=$com.instructure.Person.pronouns
|
||||||
course_start=$Canvas.course.startAt
|
course_start=$Canvas.course.startAt
|
||||||
course_end=$Canvas.course.endAt
|
course_end=$Canvas.course.endAt
|
||||||
|
grading_scheme=$com.instructure.Course.gradingScheme
|
||||||
```
|
```
|
||||||
|
|
||||||
The course dates bound the schedule and date pickers; the tool works
|
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,
|
and each student's own page. If the account has pronouns disabled,
|
||||||
everything simply shows without them.
|
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
|
## Developing without Canvas
|
||||||
|
|
||||||
Because a Developer Key takes institutional approval to get, the app
|
Because a Developer Key takes institutional approval to get, the app
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -23,11 +24,14 @@ def create_app(config=Config):
|
|||||||
app.config["SESSION_COOKIE_SAMESITE"] = "None"
|
app.config["SESSION_COOKIE_SAMESITE"] = "None"
|
||||||
app.config["SESSION_COOKIE_SECURE"] = True
|
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(lti.bp)
|
||||||
app.register_blueprint(views.bp)
|
app.register_blueprint(views.bp)
|
||||||
app.register_blueprint(instructor.bp)
|
app.register_blueprint(instructor.bp)
|
||||||
|
app.register_blueprint(grades.bp)
|
||||||
|
|
||||||
if app.config["DEV_MODE"]:
|
if app.config["DEV_MODE"]:
|
||||||
from . import dev
|
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
|
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
import datetime
|
import datetime
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from sqlalchemy import select
|
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
|
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):
|
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")
|
@app.cli.command("sync-rosters")
|
||||||
def sync_rosters():
|
def sync_rosters():
|
||||||
"""Refresh every syncable course roster from its LMS."""
|
"""Refresh every syncable course roster from its LMS."""
|
||||||
@@ -48,3 +90,156 @@ def register_cli(app):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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)
|
ensure_default_levels(db, course)
|
||||||
sync_roster(db, course, DEV_MEMBERS)
|
sync_roster(db, course, DEV_MEMBERS)
|
||||||
|
|
||||||
# A Tue/Thu meeting pattern for the next few weeks, so the opt-out
|
# A Tue/Thu 23:59 meeting pattern for the next few weeks, so the
|
||||||
# picker and schedule pages have something to show.
|
# 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):
|
if not class_days(db, course.id):
|
||||||
day = datetime.date.today()
|
day = datetime.date.today()
|
||||||
for offset in range(28):
|
for offset in range(28):
|
||||||
d = day + datetime.timedelta(days=offset)
|
d = day + datetime.timedelta(days=offset)
|
||||||
if d.weekday() in (1, 3):
|
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()
|
db.commit()
|
||||||
return course
|
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 csv
|
||||||
import datetime
|
import datetime
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -48,6 +49,15 @@ def _parse_date(value):
|
|||||||
abort(400, "Bad date; expected YYYY-MM-DD.")
|
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):
|
def _get_course_call(db, call_id):
|
||||||
call = db.get(Call, call_id)
|
call = db.get(Call, call_id)
|
||||||
if call is None or call.course_id != current_course().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)
|
.where(OptOut.course_id == course.id)
|
||||||
.order_by(OptOut.date, Student.sortable_name)
|
.order_by(OptOut.date, Student.sortable_name)
|
||||||
).all()
|
).all()
|
||||||
|
# Withdrawn opt-outs are included: the export is the audit trail.
|
||||||
return _csv_response(
|
return _csv_response(
|
||||||
"optouts.csv",
|
"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,
|
optout.date.isoformat(), student.name, student.email,
|
||||||
student.canvas_user_id, optout.created_at.isoformat(),
|
student.canvas_user_id, optout.created_at.isoformat(),
|
||||||
|
optout.withdrawn_at.isoformat() if optout.withdrawn_at else "",
|
||||||
]
|
]
|
||||||
for optout, student in rows
|
for optout, student in rows
|
||||||
],
|
],
|
||||||
@@ -380,11 +393,16 @@ def schedule_generate():
|
|||||||
if (end - start).days > 366:
|
if (end - start).days > 366:
|
||||||
abort(400, "Range longer than a year; check the dates.")
|
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)}
|
existing = {d.date for d in class_days(db, course.id)}
|
||||||
day = start
|
day = start
|
||||||
while day <= end:
|
while day <= end:
|
||||||
if day.weekday() in chosen and day not in existing:
|
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)
|
day += datetime.timedelta(days=1)
|
||||||
db.commit()
|
db.commit()
|
||||||
return redirect(url_for(".schedule"))
|
return redirect(url_for(".schedule"))
|
||||||
@@ -397,7 +415,13 @@ def schedule_add():
|
|||||||
course = current_course()
|
course = current_course()
|
||||||
day = _parse_date(request.form.get("date"))
|
day = _parse_date(request.form.get("date"))
|
||||||
if not any(d.date == day for d in class_days(db, course.id)):
|
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()
|
db.commit()
|
||||||
return redirect(url_for(".schedule"))
|
return redirect(url_for(".schedule"))
|
||||||
|
|
||||||
@@ -506,5 +530,57 @@ def settings_save():
|
|||||||
position=max((l.position for l in existing), default=-1) + 1,
|
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()
|
db.commit()
|
||||||
return redirect(url_for(".settings"))
|
return redirect(url_for(".settings"))
|
||||||
|
|||||||
@@ -26,6 +26,28 @@ def clean_custom_value(value):
|
|||||||
return 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):
|
def parse_custom_date(value):
|
||||||
"""Canvas date expansions arrive as ISO-ish timestamps; keep the
|
"""Canvas date expansions arrive as ISO-ish timestamps; keep the
|
||||||
date part and tolerate absence or junk."""
|
date part and tolerate absence or junk."""
|
||||||
@@ -54,6 +76,7 @@ class LaunchInfo:
|
|||||||
resource_link_id: str | None
|
resource_link_id: str | None
|
||||||
course_start: datetime.date | None
|
course_start: datetime.date | None
|
||||||
course_end: datetime.date | None
|
course_end: datetime.date | None
|
||||||
|
grading_scheme: str | None
|
||||||
is_instructor: bool
|
is_instructor: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +99,7 @@ def extract_launch_info(launch_data):
|
|||||||
resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"),
|
resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"),
|
||||||
course_start=parse_custom_date(custom.get("course_start")),
|
course_start=parse_custom_date(custom.get("course_start")),
|
||||||
course_end=parse_custom_date(custom.get("course_end")),
|
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, [])),
|
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
|
course.start_date = info.course_start
|
||||||
if info.course_end:
|
if info.course_end:
|
||||||
course.end_date = info.course_end
|
course.end_date = info.course_end
|
||||||
|
if info.grading_scheme:
|
||||||
|
course.canvas_grading_scheme = info.grading_scheme
|
||||||
|
|
||||||
student = db.execute(
|
student = db.execute(
|
||||||
select(Student).where(Student.canvas_user_id == info.user_id)
|
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 flask import Blueprint, abort, current_app, jsonify, redirect, session, url_for
|
||||||
from pylti1p3.contrib.flask import (
|
from pylti1p3.contrib.flask import (
|
||||||
FlaskCookieService,
|
FlaskCookieService,
|
||||||
@@ -18,6 +20,28 @@ from .roster import sync_roster
|
|||||||
bp = Blueprint("lti", __name__, url_prefix="/lti")
|
bp = Blueprint("lti", __name__, url_prefix="/lti")
|
||||||
|
|
||||||
NRPS_CLAIM = "https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice"
|
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):
|
def nrps_members_for_course(course):
|
||||||
@@ -81,6 +105,10 @@ def launch():
|
|||||||
nrps_data = launch_data.get(NRPS_CLAIM, {})
|
nrps_data = launch_data.get(NRPS_CLAIM, {})
|
||||||
if nrps_data.get("context_memberships_url"):
|
if nrps_data.get("context_memberships_url"):
|
||||||
course.nrps_url = nrps_data["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
|
# Instructor launches refresh the roster, so adds and drops are
|
||||||
# picked up before every class without a separate sync step.
|
# picked up before every class without a separate sync step.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy import (
|
|||||||
ForeignKey,
|
ForeignKey,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
Time,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
select,
|
select,
|
||||||
@@ -74,6 +75,33 @@ class Course(Base):
|
|||||||
)
|
)
|
||||||
show_assessments: Mapped[bool] = mapped_column(Boolean, default=True)
|
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(
|
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||||
DateTime, default=utcnow
|
DateTime, default=utcnow
|
||||||
)
|
)
|
||||||
@@ -166,6 +194,9 @@ class ClassDay(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
||||||
date: Mapped[datetime.date] = mapped_column(Date)
|
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):
|
class OptOut(Base):
|
||||||
@@ -179,6 +210,10 @@ class OptOut(Base):
|
|||||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||||
DateTime, default=utcnow
|
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):
|
class Call(Base):
|
||||||
@@ -205,6 +240,30 @@ class Call(Base):
|
|||||||
return self.level.label if self.level else None
|
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):
|
def answered_call_counts(session, course_id):
|
||||||
"""Number of answered calls per student, the input to selection
|
"""Number of answered calls per student, the input to selection
|
||||||
weighting. Skipped, pending, and missing calls do not count, matching
|
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):
|
def students_present(session, course_id, on_date):
|
||||||
"""Active student-role enrollees minus those opted out for the date."""
|
"""Active student-role enrollees minus those opted out for the date."""
|
||||||
opted_out = select(OptOut.student_id).where(
|
opted_out = select(OptOut.student_id).where(
|
||||||
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(
|
rows = session.execute(
|
||||||
select(Student)
|
select(Student)
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ def student_stats(db, course):
|
|||||||
|
|
||||||
optout_dates = defaultdict(set)
|
optout_dates = defaultdict(set)
|
||||||
for optout in db.execute(
|
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():
|
).scalars():
|
||||||
optout_dates[optout.student_id].add(optout.date)
|
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.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.day_edit', date_str=today.isoformat()) }}">Today's calls</a> ·
|
||||||
<a href="{{ url_for('instructor.report') }}">Report</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.schedule') }}">Schedule</a> ·
|
||||||
<a href="{{ url_for('instructor.settings') }}">Settings</a>
|
<a href="{{ url_for('instructor.settings') }}">Settings</a>
|
||||||
</p>
|
</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>
|
<h1>Class schedule</h1>
|
||||||
<p class="muted">When class days are listed here, the student opt-out
|
<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
|
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 dmin = course.start_date.isoformat() if course.start_date else "" %}
|
||||||
{% set dmax = course.end_date.isoformat() if course.end_date else "" %}
|
{% set dmax = course.end_date.isoformat() if course.end_date else "" %}
|
||||||
@@ -15,6 +17,7 @@
|
|||||||
value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"></label>
|
value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||||
<label>to <input type="date" name="end" required
|
<label>to <input type="date" name="end" required
|
||||||
value="{{ dmax }}" min="{{ dmin }}" max="{{ dmax }}"></label>
|
value="{{ dmax }}" min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||||
|
<label>starting at <input type="time" name="start_time"></label>
|
||||||
{% for i, name in weekdays %}
|
{% for i, name in weekdays %}
|
||||||
<label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label>
|
<label><input type="checkbox" name="weekday" value="{{ i }}"> {{ name }}</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
<form method="post" action="{{ url_for('instructor.schedule_add') }}">
|
<form method="post" action="{{ url_for('instructor.schedule_add') }}">
|
||||||
<label>Date <input type="date" name="date" required
|
<label>Date <input type="date" name="date" required
|
||||||
min="{{ dmin }}" max="{{ dmax }}"></label>
|
min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||||
|
<label>starting at <input type="time" name="start_time"></label>
|
||||||
<button type="submit">Add</button>
|
<button type="submit">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -34,7 +38,9 @@
|
|||||||
{% for d in days %}
|
{% for d in days %}
|
||||||
<tr>
|
<tr>
|
||||||
<td {% if d.date < today %}class="muted"{% endif %}>
|
<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>
|
<td>
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{{ url_for('instructor.schedule_delete', day_id=d.id) }}">
|
action="{{ url_for('instructor.schedule_delete', day_id=d.id) }}">
|
||||||
|
|||||||
@@ -63,6 +63,81 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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>
|
<p><button type="submit">Save</button></p>
|
||||||
</form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
they are not right, update them in your Canvas account settings and
|
they are not right, update them in your Canvas account settings and
|
||||||
they will be picked up here automatically.</p>
|
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>
|
<h2>Your standing</h2>
|
||||||
<p>
|
<p>
|
||||||
You have answered <strong>{{ answered }}</strong>
|
You have answered <strong>{{ answered }}</strong>
|
||||||
@@ -47,22 +52,23 @@
|
|||||||
{% if optouts %}
|
{% if optouts %}
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Date</th><th></th></tr>
|
<tr><th>Date</th><th></th></tr>
|
||||||
{% for o in optouts %}
|
{% for o, withdrawable in optouts %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ o.date.isoformat() }}</td>
|
<td>{{ o.date.isoformat() }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if o.date >= today %}
|
{% if withdrawable %}
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{{ url_for('views.optout_delete', optout_id=o.id) }}">
|
action="{{ url_for('views.optout_delete', optout_id=o.id) }}">
|
||||||
<button type="submit">Withdraw</button>
|
<button type="submit">Withdraw</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted">past</span>
|
<span class="muted">locked</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
<p class="muted">Opt-outs can be withdrawn until the class begins.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">You have not opted out of any classes.</p>
|
<p class="muted">You have not opted out of any classes.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ from .models import (
|
|||||||
Student,
|
Student,
|
||||||
answered_call_counts,
|
answered_call_counts,
|
||||||
class_days,
|
class_days,
|
||||||
|
class_has_begun,
|
||||||
is_class_day,
|
is_class_day,
|
||||||
|
latest_grade_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
bp = Blueprint("views", __name__)
|
bp = Blueprint("views", __name__)
|
||||||
@@ -119,17 +121,22 @@ def student_home():
|
|||||||
db.execute(
|
db.execute(
|
||||||
select(OptOut)
|
select(OptOut)
|
||||||
.where(
|
.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)
|
.order_by(OptOut.date)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
optouts = [
|
||||||
|
(o, not class_has_begun(db, course.id, o.date)) for o in optouts
|
||||||
|
]
|
||||||
upcoming_days = [
|
upcoming_days = [
|
||||||
d.date
|
d.date
|
||||||
for d in class_days(db, course.id, start=today)
|
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(
|
return render_template(
|
||||||
@@ -143,6 +150,34 @@ def student_home():
|
|||||||
optouts=optouts,
|
optouts=optouts,
|
||||||
upcoming_days=upcoming_days,
|
upcoming_days=upcoming_days,
|
||||||
distribution=_class_call_distribution(db, course, user.id),
|
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:
|
if exists is None:
|
||||||
db.add(OptOut(course_id=course.id, student_id=user.id, date=day))
|
db.add(OptOut(course_id=course.id, student_id=user.id, date=day))
|
||||||
db.commit()
|
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"))
|
return redirect(url_for(".student_home"))
|
||||||
|
|
||||||
|
|
||||||
@@ -184,10 +224,11 @@ def optout_delete(optout_id):
|
|||||||
optout is None
|
optout is None
|
||||||
or optout.course_id != current_course().id
|
or optout.course_id != current_course().id
|
||||||
or optout.student_id != current_user().id
|
or optout.student_id != current_user().id
|
||||||
|
or optout.withdrawn_at is not None
|
||||||
):
|
):
|
||||||
abort(404)
|
abort(404)
|
||||||
if optout.date < datetime.date.today():
|
if class_has_begun(db, optout.course_id, optout.date):
|
||||||
abort(400, "Past opt-outs cannot be withdrawn.")
|
abort(400, "Opt-outs cannot be withdrawn once class has begun.")
|
||||||
db.delete(optout)
|
optout.withdrawn_at = datetime.datetime.now(datetime.UTC)
|
||||||
db.commit()
|
db.commit()
|
||||||
return redirect(url_for(".student_home"))
|
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
|
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):
|
def test_assessment_visibility_setting(student):
|
||||||
# Record an answered call for dev-student-1 as the instructor.
|
# Record an answered call for dev-student-1 as the instructor.
|
||||||
student.post("/dev/launch/dev-instructor")
|
student.post("/dev/launch/dev-instructor")
|
||||||
|
|||||||
Reference in New Issue
Block a user