Phase 5: reporting, exports, pronouns, and roster freshness
Instructor participation report: per-student histograms, outcome mix by class day, and a sortable table including the fairness ratio (answered calls over questions present for, with opt-out days out of the denominator), plus CSV exports of students, calls, and opt-outs. Assessment scales are now per-course data: ordered levels with labels and points out of 100 (defaults carry the old R grading values), with calls referencing levels by id so renames follow through to history. Renaming, re-pointing, reordering, and adding levels are always allowed; deleting a level in use by recorded calls is blocked. Pronouns and course term dates come from Canvas custom variable substitutions, at launch and roster-wide via rlid-scoped NRPS; the student page notes that names/pronouns are Canvas-sourced. Rosters can also be refreshed outside launches: a "Sync roster now" button and a sync-rosters CLI command for an hourly cron job, skipping ended courses. Alembic now runs SQLite-compatible batch migrations with a constraint naming convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
32
README.md
32
README.md
@@ -83,7 +83,37 @@ openssl rsa -in instance/private.key -pubout -out instance/public.key
|
|||||||
|
|
||||||
On each instructor launch the tool refreshes the course roster from
|
On each instructor launch the tool refreshes the course roster from
|
||||||
Canvas through the Names and Role Provisioning Service, so enrollment
|
Canvas through the Names and Role Provisioning Service, so enrollment
|
||||||
changes appear without any manual step.
|
changes appear without any manual step. There is also a "Sync roster
|
||||||
|
now" button on the instructor page, and a management command suitable
|
||||||
|
for an hourly cron job on the server, which keeps rosters current even
|
||||||
|
when nobody has launched the tool (worth having during the add/drop
|
||||||
|
churn at the start of a term):
|
||||||
|
|
||||||
|
```
|
||||||
|
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
|
||||||
|
```
|
||||||
|
|
||||||
|
Courses whose Canvas end date has passed are skipped automatically.
|
||||||
|
|
||||||
|
Student names come from Canvas display names, which already reflect
|
||||||
|
preferred names. Pronouns require one extra piece of Developer Key
|
||||||
|
configuration: add a custom parameter
|
||||||
|
|
||||||
|
```
|
||||||
|
pronouns=$com.instructure.Person.pronouns
|
||||||
|
course_start=$Canvas.course.startAt
|
||||||
|
course_end=$Canvas.course.endAt
|
||||||
|
```
|
||||||
|
|
||||||
|
The course dates bound the schedule and date pickers; the tool works
|
||||||
|
fine without them when a course has no dates set in Canvas.
|
||||||
|
|
||||||
|
to the key. Canvas then includes each person's pronouns in launches
|
||||||
|
and in the roster data (the tool requests memberships scoped to the
|
||||||
|
resource link, which is what makes Canvas attach per-member custom
|
||||||
|
fields). Pronouns appear on the live call card, printed call lists,
|
||||||
|
and each student's own page. If the account has pronouns disabled,
|
||||||
|
everything simply shows without them.
|
||||||
|
|
||||||
## Developing without Canvas
|
## Developing without Canvas
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ def create_app(config=Config):
|
|||||||
|
|
||||||
app.register_blueprint(dev.bp)
|
app.register_blueprint(dev.bp)
|
||||||
|
|
||||||
|
from .cli import register_cli
|
||||||
|
|
||||||
|
register_cli(app)
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
50
coldcall_lti/cli.py
Normal file
50
coldcall_lti/cli.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Management commands, run as `flask --app coldcall_lti <command>`.
|
||||||
|
|
||||||
|
sync-rosters exists so a crontab entry can keep rosters fresh between
|
||||||
|
launches (enrollment shifts constantly in the first weeks of a term):
|
||||||
|
|
||||||
|
17 * * * * cd /path/to/coldcall_lti && .venv/bin/flask --app coldcall_lti sync-rosters
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask import current_app
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from .models import Course
|
||||||
|
from .roster import sync_roster
|
||||||
|
|
||||||
|
|
||||||
|
def register_cli(app):
|
||||||
|
@app.cli.command("sync-rosters")
|
||||||
|
def sync_rosters():
|
||||||
|
"""Refresh every syncable course roster from its LMS."""
|
||||||
|
from .lti import nrps_members_for_course
|
||||||
|
|
||||||
|
db = current_app.extensions["db_session_factory"]()
|
||||||
|
today = datetime.date.today()
|
||||||
|
try:
|
||||||
|
for course in db.execute(select(Course)).scalars():
|
||||||
|
label = course.title or course.lti_context_id
|
||||||
|
if course.end_date and course.end_date < today:
|
||||||
|
click.echo(f"{label}: ended, skipped")
|
||||||
|
continue
|
||||||
|
if not course.nrps_url:
|
||||||
|
click.echo(f"{label}: no roster service recorded, skipped")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
members = nrps_members_for_course(course)
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(f"{label}: sync failed: {exc}", err=True)
|
||||||
|
continue
|
||||||
|
if members is None:
|
||||||
|
click.echo(f"{label}: no registration found, skipped")
|
||||||
|
continue
|
||||||
|
active, deactivated = sync_roster(db, course, members)
|
||||||
|
db.commit()
|
||||||
|
click.echo(
|
||||||
|
f"{label}: {active} active, {deactivated} deactivated"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
from flask import current_app, g
|
from flask import current_app, g
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import MetaData, create_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
# Deterministic constraint names, required for SQLite batch migrations
|
||||||
|
# and portable to MariaDB/MySQL.
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
|
|
||||||
|
|
||||||
def make_engine(url, echo=False):
|
def make_engine(url, echo=False):
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .launch import ROLE_INSTRUCTOR, ROLE_LEARNER
|
from .launch import ROLE_INSTRUCTOR, ROLE_LEARNER
|
||||||
from .models import ClassDay, Course, Student, class_days
|
from .models import ClassDay, Course, Student, class_days, ensure_default_levels
|
||||||
from .roster import sync_roster
|
from .roster import sync_roster
|
||||||
|
|
||||||
bp = Blueprint("dev", __name__, url_prefix="/dev")
|
bp = Blueprint("dev", __name__, url_prefix="/dev")
|
||||||
@@ -28,6 +28,7 @@ DEV_MEMBERS = [
|
|||||||
"given_name": "Ida",
|
"given_name": "Ida",
|
||||||
"family_name": "Instructor",
|
"family_name": "Instructor",
|
||||||
"email": "instructor@example.edu",
|
"email": "instructor@example.edu",
|
||||||
|
"pronouns": "she/her",
|
||||||
"roles": [ROLE_INSTRUCTOR],
|
"roles": [ROLE_INSTRUCTOR],
|
||||||
"status": "Active",
|
"status": "Active",
|
||||||
}
|
}
|
||||||
@@ -38,19 +39,20 @@ DEV_MEMBERS = [
|
|||||||
"given_name": given,
|
"given_name": given,
|
||||||
"family_name": family,
|
"family_name": family,
|
||||||
"email": f"{given.lower()}@example.edu",
|
"email": f"{given.lower()}@example.edu",
|
||||||
|
"pronouns": pronouns,
|
||||||
"roles": [ROLE_LEARNER],
|
"roles": [ROLE_LEARNER],
|
||||||
"status": "Active",
|
"status": "Active",
|
||||||
}
|
}
|
||||||
for i, (given, family) in enumerate(
|
for i, (given, family, pronouns) in enumerate(
|
||||||
[
|
[
|
||||||
("Ada", "Lovelace"),
|
("Ada", "Lovelace", "she/her"),
|
||||||
("Grace", "Hopper"),
|
("Grace", "Hopper", "she/her"),
|
||||||
("Alan", "Turing"),
|
("Alan", "Turing", "he/him"),
|
||||||
("Annie", "Easley"),
|
("Annie", "Easley", "she/her"),
|
||||||
("Edsger", "Dijkstra"),
|
("Edsger", "Dijkstra", None),
|
||||||
("Katherine", "Johnson"),
|
("Katherine", "Johnson", "she/her"),
|
||||||
("Donald", "Knuth"),
|
("Donald", "Knuth", "he/him"),
|
||||||
("Radia", "Perlman"),
|
("Radia", "Perlman", None),
|
||||||
],
|
],
|
||||||
start=1,
|
start=1,
|
||||||
)
|
)
|
||||||
@@ -62,9 +64,16 @@ def _ensure_dev_course(db):
|
|||||||
select(Course).where(Course.lti_context_id == DEV_CONTEXT_ID)
|
select(Course).where(Course.lti_context_id == DEV_CONTEXT_ID)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if course is None:
|
if course is None:
|
||||||
course = Course(lti_context_id=DEV_CONTEXT_ID, title="Development Course")
|
today = datetime.date.today()
|
||||||
|
course = Course(
|
||||||
|
lti_context_id=DEV_CONTEXT_ID,
|
||||||
|
title="Development Course",
|
||||||
|
start_date=today - datetime.timedelta(days=14),
|
||||||
|
end_date=today + datetime.timedelta(days=56),
|
||||||
|
)
|
||||||
db.add(course)
|
db.add(course)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
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 meeting pattern for the next few weeks, so the opt-out
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import csv
|
||||||
import datetime
|
import datetime
|
||||||
|
import io
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
|
Response,
|
||||||
abort,
|
abort,
|
||||||
|
current_app,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
request,
|
request,
|
||||||
@@ -10,24 +14,26 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, select
|
||||||
|
|
||||||
from . import calls
|
from . import calls, reports
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .models import (
|
from .models import (
|
||||||
ASSESSMENTS,
|
|
||||||
CALL_STATUSES,
|
CALL_STATUSES,
|
||||||
SELECTION_CYCLE,
|
SELECTION_CYCLE,
|
||||||
SELECTION_WEIGHTED,
|
SELECTION_WEIGHTED,
|
||||||
STATUS_ANSWERED,
|
STATUS_ANSWERED,
|
||||||
STATUS_MISSING,
|
STATUS_MISSING,
|
||||||
STATUS_SKIPPED,
|
STATUS_SKIPPED,
|
||||||
|
AssessmentLevel,
|
||||||
Call,
|
Call,
|
||||||
ClassDay,
|
ClassDay,
|
||||||
Enrollment,
|
Enrollment,
|
||||||
OptOut,
|
OptOut,
|
||||||
Student,
|
Student,
|
||||||
|
assessment_levels,
|
||||||
class_days,
|
class_days,
|
||||||
students_present,
|
students_present,
|
||||||
)
|
)
|
||||||
|
from .roster import sync_roster
|
||||||
from .views import current_course, require_instructor
|
from .views import current_course, require_instructor
|
||||||
|
|
||||||
bp = Blueprint("instructor", __name__, url_prefix="/instructor")
|
bp = Blueprint("instructor", __name__, url_prefix="/instructor")
|
||||||
@@ -106,7 +112,11 @@ def live():
|
|||||||
if c.status != calls.STATUS_PENDING
|
if c.status != calls.STATUS_PENDING
|
||||||
]
|
]
|
||||||
context = _day_context(db, course, day)
|
context = _day_context(db, course, day)
|
||||||
context.update(call=call, day_calls=day_calls, assessments=ASSESSMENTS)
|
context.update(
|
||||||
|
call=call,
|
||||||
|
day_calls=day_calls,
|
||||||
|
levels=assessment_levels(db, course.id),
|
||||||
|
)
|
||||||
return render_template("live.html", **context)
|
return render_template("live.html", **context)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,15 +140,18 @@ def call_outcome(call_id):
|
|||||||
call = _get_course_call(db, call_id)
|
call = _get_course_call(db, call_id)
|
||||||
|
|
||||||
action = request.form.get("action", "")
|
action = request.form.get("action", "")
|
||||||
if action in ASSESSMENTS:
|
if action.startswith("level-"):
|
||||||
|
level = db.get(AssessmentLevel, int(action.removeprefix("level-")))
|
||||||
|
if level is None or level.course_id != call.course_id:
|
||||||
|
abort(400, "Unknown assessment level.")
|
||||||
call.status = STATUS_ANSWERED
|
call.status = STATUS_ANSWERED
|
||||||
call.assessment = action
|
call.assessment_id = level.id
|
||||||
elif action == "skip":
|
elif action == "skip":
|
||||||
call.status = STATUS_SKIPPED
|
call.status = STATUS_SKIPPED
|
||||||
call.assessment = None
|
call.assessment_id = None
|
||||||
elif action == "missing":
|
elif action == "missing":
|
||||||
call.status = STATUS_MISSING
|
call.status = STATUS_MISSING
|
||||||
call.assessment = None
|
call.assessment_id = None
|
||||||
else:
|
else:
|
||||||
abort(400, "Unknown outcome.")
|
abort(400, "Unknown outcome.")
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -182,7 +195,7 @@ def day_edit(date_str):
|
|||||||
context = _day_context(db, course, day)
|
context = _day_context(db, course, day)
|
||||||
context.update(
|
context.update(
|
||||||
day_calls=calls.calls_for_day(db, course.id, day),
|
day_calls=calls.calls_for_day(db, course.id, day),
|
||||||
assessments=ASSESSMENTS,
|
levels=assessment_levels(db, course.id),
|
||||||
statuses=CALL_STATUSES,
|
statuses=CALL_STATUSES,
|
||||||
)
|
)
|
||||||
return render_template("day_edit.html", **context)
|
return render_template("day_edit.html", **context)
|
||||||
@@ -195,6 +208,7 @@ def day_save(date_str):
|
|||||||
course = current_course()
|
course = current_course()
|
||||||
day = _parse_date(date_str)
|
day = _parse_date(date_str)
|
||||||
|
|
||||||
|
level_ids = {lvl.id for lvl in assessment_levels(db, course.id)}
|
||||||
for call in calls.calls_for_day(db, course.id, day):
|
for call in calls.calls_for_day(db, course.id, day):
|
||||||
if request.form.get(f"delete-{call.id}"):
|
if request.form.get(f"delete-{call.id}"):
|
||||||
db.delete(call)
|
db.delete(call)
|
||||||
@@ -202,14 +216,141 @@ def day_save(date_str):
|
|||||||
status = request.form.get(f"status-{call.id}")
|
status = request.form.get(f"status-{call.id}")
|
||||||
if status in CALL_STATUSES:
|
if status in CALL_STATUSES:
|
||||||
call.status = status
|
call.status = status
|
||||||
assessment = request.form.get(f"assessment-{call.id}", "")
|
assessment = request.form.get(f"assessment-{call.id}", type=int)
|
||||||
call.assessment = assessment if assessment in ASSESSMENTS else None
|
call.assessment_id = assessment if assessment in level_ids else None
|
||||||
note = request.form.get(f"note-{call.id}", "").strip()
|
note = request.form.get(f"note-{call.id}", "").strip()
|
||||||
call.note = note or None
|
call.note = note or None
|
||||||
db.commit()
|
db.commit()
|
||||||
return redirect(url_for(".day_edit", date_str=day.isoformat()))
|
return redirect(url_for(".day_edit", date_str=day.isoformat()))
|
||||||
|
|
||||||
|
|
||||||
|
REPORT_SORTS = {
|
||||||
|
"name": lambda r: (r["student"].sortable_name or r["student"].name or ""),
|
||||||
|
"answered": lambda r: -r["answered"],
|
||||||
|
"missing": lambda r: -r["missing"],
|
||||||
|
"optouts": lambda r: -r["optouts"],
|
||||||
|
"prop": lambda r: -(r["prop_asked"] if r["prop_asked"] is not None else -1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/report")
|
||||||
|
@require_instructor
|
||||||
|
def report():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
|
||||||
|
rows = reports.student_stats(db, course)
|
||||||
|
sort = request.args.get("sort", "name")
|
||||||
|
rows.sort(key=REPORT_SORTS.get(sort, REPORT_SORTS["name"]))
|
||||||
|
|
||||||
|
by_day = reports.assessments_by_day(db, course.id)
|
||||||
|
outcome_totals = {}
|
||||||
|
for day in by_day:
|
||||||
|
for k, v in day["counts"].items():
|
||||||
|
outcome_totals[k] = outcome_totals.get(k, 0) + v
|
||||||
|
|
||||||
|
level_labels = [lvl.label for lvl in assessment_levels(db, course.id)]
|
||||||
|
return render_template(
|
||||||
|
"report.html",
|
||||||
|
course=course,
|
||||||
|
rows=rows,
|
||||||
|
sort=sort,
|
||||||
|
calls_hist=reports.histogram([r["answered"] for r in rows]),
|
||||||
|
optouts_hist=reports.histogram([r["optouts"] for r in rows]),
|
||||||
|
by_day=by_day,
|
||||||
|
outcome_totals=outcome_totals,
|
||||||
|
total_questions=sum(d["total"] for d in by_day),
|
||||||
|
outcome_order=level_labels + ["(unassessed)", "missing"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_response(filename, header, rows):
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(header)
|
||||||
|
writer.writerows(rows)
|
||||||
|
return Response(
|
||||||
|
buf.getvalue(),
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/export/students.csv")
|
||||||
|
@require_instructor
|
||||||
|
def export_students():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
return _csv_response(
|
||||||
|
"students.csv",
|
||||||
|
[
|
||||||
|
"name", "sortable_name", "email", "canvas_user_id",
|
||||||
|
"answered", "missing", "optouts",
|
||||||
|
"questions_present", "prop_asked",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
r["student"].name, r["student"].sortable_name,
|
||||||
|
r["student"].email, r["student"].canvas_user_id,
|
||||||
|
r["answered"], r["missing"], r["optouts"],
|
||||||
|
r["questions_present"],
|
||||||
|
round(r["prop_asked"], 4) if r["prop_asked"] is not None else "",
|
||||||
|
]
|
||||||
|
for r in reports.student_stats(db, course)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/export/calls.csv")
|
||||||
|
@require_instructor
|
||||||
|
def export_calls():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
rows = db.execute(
|
||||||
|
select(Call, Student)
|
||||||
|
.join(Student, Call.student_id == Student.id)
|
||||||
|
.where(Call.course_id == course.id)
|
||||||
|
.order_by(Call.session_date, Call.id)
|
||||||
|
).all()
|
||||||
|
return _csv_response(
|
||||||
|
"calls.csv",
|
||||||
|
["date", "name", "email", "canvas_user_id",
|
||||||
|
"status", "assessment", "note", "created_at"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
call.session_date.isoformat(), student.name, student.email,
|
||||||
|
student.canvas_user_id, call.status, call.assessment or "",
|
||||||
|
call.note or "", call.created_at.isoformat(),
|
||||||
|
]
|
||||||
|
for call, student in rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/export/optouts.csv")
|
||||||
|
@require_instructor
|
||||||
|
def export_optouts():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
rows = db.execute(
|
||||||
|
select(OptOut, Student)
|
||||||
|
.join(Student, OptOut.student_id == Student.id)
|
||||||
|
.where(OptOut.course_id == course.id)
|
||||||
|
.order_by(OptOut.date, Student.sortable_name)
|
||||||
|
).all()
|
||||||
|
return _csv_response(
|
||||||
|
"optouts.csv",
|
||||||
|
["date", "name", "email", "canvas_user_id", "submitted_at"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
optout.date.isoformat(), student.name, student.email,
|
||||||
|
student.canvas_user_id, optout.created_at.isoformat(),
|
||||||
|
]
|
||||||
|
for optout, student in rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/schedule")
|
@bp.get("/schedule")
|
||||||
@require_instructor
|
@require_instructor
|
||||||
def schedule():
|
def schedule():
|
||||||
@@ -273,16 +414,53 @@ def schedule_delete(day_id):
|
|||||||
return redirect(url_for(".schedule"))
|
return redirect(url_for(".schedule"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/roster/sync")
|
||||||
|
@require_instructor
|
||||||
|
def roster_sync():
|
||||||
|
from .lti import nrps_members_for_course
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
|
||||||
|
members = nrps_members_for_course(course)
|
||||||
|
if members is None and current_app.config["DEV_MODE"]:
|
||||||
|
from .dev import DEV_MEMBERS
|
||||||
|
|
||||||
|
members = DEV_MEMBERS
|
||||||
|
if members is None:
|
||||||
|
abort(400, "No roster service recorded for this course yet.")
|
||||||
|
sync_roster(db, course, members)
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for(".home"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/settings")
|
@bp.get("/settings")
|
||||||
@require_instructor
|
@require_instructor
|
||||||
def settings():
|
def settings():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
return render_template(
|
return render_template(
|
||||||
"settings.html",
|
"settings.html",
|
||||||
course=current_course(),
|
course=course,
|
||||||
|
levels=assessment_levels(db, course.id),
|
||||||
|
level_use=_level_use_counts(db, course.id),
|
||||||
modes=(SELECTION_WEIGHTED, SELECTION_CYCLE),
|
modes=(SELECTION_WEIGHTED, SELECTION_CYCLE),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _level_use_counts(db, course_id):
|
||||||
|
return dict(
|
||||||
|
db.execute(
|
||||||
|
select(Call.assessment_id, func.count())
|
||||||
|
.where(
|
||||||
|
Call.course_id == course_id,
|
||||||
|
Call.assessment_id.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(Call.assessment_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/settings")
|
@bp.post("/settings")
|
||||||
@require_instructor
|
@require_instructor
|
||||||
def settings_save():
|
def settings_save():
|
||||||
@@ -296,5 +474,37 @@ def settings_save():
|
|||||||
if weight and weight >= 1.0:
|
if weight and weight >= 1.0:
|
||||||
course.weight_factor = weight
|
course.weight_factor = weight
|
||||||
course.show_assessments = bool(request.form.get("show_assessments"))
|
course.show_assessments = bool(request.form.get("show_assessments"))
|
||||||
|
|
||||||
|
# Assessment scale. Levels in use by recorded calls cannot be
|
||||||
|
# deleted (rename or re-point them instead).
|
||||||
|
used = set(_level_use_counts(db, course.id))
|
||||||
|
for level in assessment_levels(db, course.id):
|
||||||
|
if f"label-{level.id}" not in request.form:
|
||||||
|
continue
|
||||||
|
if request.form.get(f"delete-{level.id}") and level.id not in used:
|
||||||
|
db.delete(level)
|
||||||
|
continue
|
||||||
|
label = request.form.get(f"label-{level.id}", "").strip()
|
||||||
|
points = request.form.get(f"points-{level.id}", type=float)
|
||||||
|
position = request.form.get(f"position-{level.id}", type=int)
|
||||||
|
if label:
|
||||||
|
level.label = label
|
||||||
|
if points is not None and 0 <= points <= 100:
|
||||||
|
level.points = points
|
||||||
|
if position is not None:
|
||||||
|
level.position = position
|
||||||
|
|
||||||
|
new_label = request.form.get("new_label", "").strip()
|
||||||
|
new_points = request.form.get("new_points", type=float)
|
||||||
|
if new_label and new_points is not None and 0 <= new_points <= 100:
|
||||||
|
existing = assessment_levels(db, course.id)
|
||||||
|
db.add(
|
||||||
|
AssessmentLevel(
|
||||||
|
course_id=course.id,
|
||||||
|
label=new_label,
|
||||||
|
points=new_points,
|
||||||
|
position=max((l.position for l in existing), default=-1) + 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return redirect(url_for(".settings"))
|
return redirect(url_for(".settings"))
|
||||||
|
|||||||
@@ -4,15 +4,38 @@ Kept separate from the Flask/pylti1p3 plumbing so it can be tested with
|
|||||||
plain dicts of claims and reused by the dev-mode fake launcher.
|
plain dicts of claims and reused by the dev-mode fake launcher.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from .models import Course, Enrollment, Student
|
from .models import Course, Enrollment, Student, ensure_default_levels
|
||||||
|
|
||||||
CLAIM_CONTEXT = "https://purl.imsglobal.org/spec/lti/claim/context"
|
CLAIM_CONTEXT = "https://purl.imsglobal.org/spec/lti/claim/context"
|
||||||
CLAIM_ROLES = "https://purl.imsglobal.org/spec/lti/claim/roles"
|
CLAIM_ROLES = "https://purl.imsglobal.org/spec/lti/claim/roles"
|
||||||
CLAIM_DEPLOYMENT = "https://purl.imsglobal.org/spec/lti/claim/deployment_id"
|
CLAIM_DEPLOYMENT = "https://purl.imsglobal.org/spec/lti/claim/deployment_id"
|
||||||
|
CLAIM_CUSTOM = "https://purl.imsglobal.org/spec/lti/claim/custom"
|
||||||
|
CLAIM_RESOURCE_LINK = "https://purl.imsglobal.org/spec/lti/claim/resource_link"
|
||||||
|
|
||||||
|
|
||||||
|
def clean_custom_value(value):
|
||||||
|
"""Custom parameters echo back their '$...' variable name when the
|
||||||
|
platform doesn't support the expansion; treat that as absent."""
|
||||||
|
if not value or str(value).startswith("$"):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse_custom_date(value):
|
||||||
|
"""Canvas date expansions arrive as ISO-ish timestamps; keep the
|
||||||
|
date part and tolerate absence or junk."""
|
||||||
|
value = clean_custom_value(value)
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.date.fromisoformat(str(value)[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
ROLE_INSTRUCTOR = "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"
|
ROLE_INSTRUCTOR = "http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor"
|
||||||
ROLE_LEARNER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
|
ROLE_LEARNER = "http://purl.imsglobal.org/vocab/lis/v2/membership#Learner"
|
||||||
@@ -27,6 +50,10 @@ class LaunchInfo:
|
|||||||
name: str | None
|
name: str | None
|
||||||
email: str | None
|
email: str | None
|
||||||
picture: str | None
|
picture: str | None
|
||||||
|
pronouns: str | None
|
||||||
|
resource_link_id: str | None
|
||||||
|
course_start: datetime.date | None
|
||||||
|
course_end: datetime.date | None
|
||||||
is_instructor: bool
|
is_instructor: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +63,7 @@ def roles_include_instructor(roles):
|
|||||||
|
|
||||||
def extract_launch_info(launch_data):
|
def extract_launch_info(launch_data):
|
||||||
context = launch_data.get(CLAIM_CONTEXT, {})
|
context = launch_data.get(CLAIM_CONTEXT, {})
|
||||||
|
custom = launch_data.get(CLAIM_CUSTOM, {})
|
||||||
return LaunchInfo(
|
return LaunchInfo(
|
||||||
context_id=context["id"],
|
context_id=context["id"],
|
||||||
deployment_id=launch_data.get(CLAIM_DEPLOYMENT),
|
deployment_id=launch_data.get(CLAIM_DEPLOYMENT),
|
||||||
@@ -44,6 +72,10 @@ def extract_launch_info(launch_data):
|
|||||||
name=launch_data.get("name"),
|
name=launch_data.get("name"),
|
||||||
email=launch_data.get("email"),
|
email=launch_data.get("email"),
|
||||||
picture=launch_data.get("picture"),
|
picture=launch_data.get("picture"),
|
||||||
|
pronouns=clean_custom_value(custom.get("pronouns")),
|
||||||
|
resource_link_id=launch_data.get(CLAIM_RESOURCE_LINK, {}).get("id"),
|
||||||
|
course_start=parse_custom_date(custom.get("course_start")),
|
||||||
|
course_end=parse_custom_date(custom.get("course_end")),
|
||||||
is_instructor=roles_include_instructor(launch_data.get(CLAIM_ROLES, [])),
|
is_instructor=roles_include_instructor(launch_data.get(CLAIM_ROLES, [])),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,10 +88,16 @@ def upsert_launch(db, info):
|
|||||||
if course is None:
|
if course is None:
|
||||||
course = Course(lti_context_id=info.context_id)
|
course = Course(lti_context_id=info.context_id)
|
||||||
db.add(course)
|
db.add(course)
|
||||||
|
db.flush()
|
||||||
|
ensure_default_levels(db, course)
|
||||||
if info.course_title:
|
if info.course_title:
|
||||||
course.title = info.course_title
|
course.title = info.course_title
|
||||||
if info.deployment_id:
|
if info.deployment_id:
|
||||||
course.lti_deployment_id = info.deployment_id
|
course.lti_deployment_id = info.deployment_id
|
||||||
|
if info.course_start:
|
||||||
|
course.start_date = info.course_start
|
||||||
|
if info.course_end:
|
||||||
|
course.end_date = info.course_end
|
||||||
|
|
||||||
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)
|
||||||
@@ -73,6 +111,8 @@ def upsert_launch(db, info):
|
|||||||
student.email = info.email
|
student.email = info.email
|
||||||
if info.picture:
|
if info.picture:
|
||||||
student.avatar_url = info.picture
|
student.avatar_url = info.picture
|
||||||
|
if info.pronouns:
|
||||||
|
student.pronouns = info.pronouns
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
enrollment = db.execute(
|
enrollment = db.execute(
|
||||||
|
|||||||
@@ -8,12 +8,35 @@ from pylti1p3.contrib.flask import (
|
|||||||
)
|
)
|
||||||
from pylti1p3.tool_config import ToolConfJsonFile
|
from pylti1p3.tool_config import ToolConfJsonFile
|
||||||
|
|
||||||
|
from pylti1p3.names_roles import NamesRolesProvisioningService
|
||||||
|
from pylti1p3.service_connector import ServiceConnector
|
||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .launch import extract_launch_info, upsert_launch
|
from .launch import extract_launch_info, upsert_launch
|
||||||
from .roster import sync_roster
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def nrps_members_for_course(course):
|
||||||
|
"""Fetch the course roster from NRPS outside a launch, using the
|
||||||
|
platform details captured at the last launch. Returns None if the
|
||||||
|
course hasn't recorded them yet."""
|
||||||
|
if not (course.nrps_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
|
||||||
|
service = NamesRolesProvisioningService(
|
||||||
|
ServiceConnector(registration),
|
||||||
|
{"context_memberships_url": course.nrps_url},
|
||||||
|
)
|
||||||
|
return service.get_members()
|
||||||
|
|
||||||
|
|
||||||
def get_tool_conf():
|
def get_tool_conf():
|
||||||
return ToolConfJsonFile(current_app.config["LTI_CONFIG_PATH"])
|
return ToolConfJsonFile(current_app.config["LTI_CONFIG_PATH"])
|
||||||
@@ -50,10 +73,23 @@ def launch():
|
|||||||
db = get_db()
|
db = get_db()
|
||||||
course, student = upsert_launch(db, info)
|
course, student = upsert_launch(db, info)
|
||||||
|
|
||||||
|
# Remember how to reach NRPS outside a launch (the manual roster
|
||||||
|
# refresh); aud may be a single client id or a list.
|
||||||
|
aud = launch_data.get("aud")
|
||||||
|
course.lti_issuer = launch_data.get("iss")
|
||||||
|
course.lti_client_id = aud[0] if isinstance(aud, list) else aud
|
||||||
|
nrps_data = launch_data.get(NRPS_CLAIM, {})
|
||||||
|
if nrps_data.get("context_memberships_url"):
|
||||||
|
course.nrps_url = nrps_data["context_memberships_url"]
|
||||||
|
|
||||||
# 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.
|
||||||
if info.is_instructor and message_launch.has_nrps():
|
if info.is_instructor and message_launch.has_nrps():
|
||||||
members = message_launch.get_nrps().get_members()
|
# Scoping to the resource link makes Canvas include per-member
|
||||||
|
# custom expansions (pronouns) in the membership response.
|
||||||
|
members = message_launch.get_nrps().get_members(
|
||||||
|
resource_link_id=info.resource_link_id
|
||||||
|
)
|
||||||
sync_roster(db, course, members)
|
sync_roster(db, course, members)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,16 @@ STATUS_SKIPPED = "skipped"
|
|||||||
STATUS_PENDING = "pending"
|
STATUS_PENDING = "pending"
|
||||||
CALL_STATUSES = (STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, STATUS_PENDING)
|
CALL_STATUSES = (STATUS_ANSWERED, STATUS_MISSING, STATUS_SKIPPED, STATUS_PENDING)
|
||||||
|
|
||||||
# Default assessment vocabulary, carried over from the manual system.
|
# Default assessment vocabulary and points, carried over from the
|
||||||
ASSESSMENTS = ("GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER")
|
# manual system: compute_final_case_grades.R scored GOOD=100,
|
||||||
|
# SATISFACTORY and POOR one and two UW-4.0-scale steps down
|
||||||
|
# (100 - n*50/(4-0.7)), and NO MEANINGFUL ANSWER zero.
|
||||||
|
DEFAULT_ASSESSMENT_LEVELS = (
|
||||||
|
("GOOD", 100.0),
|
||||||
|
("SATISFACTORY", 84.85),
|
||||||
|
("POOR", 69.7),
|
||||||
|
("NO MEANINGFUL ANSWER", 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
SELECTION_WEIGHTED = "weighted"
|
SELECTION_WEIGHTED = "weighted"
|
||||||
SELECTION_CYCLE = "cycle"
|
SELECTION_CYCLE = "cycle"
|
||||||
@@ -47,6 +55,18 @@ class Course(Base):
|
|||||||
lti_deployment_id: Mapped[str | None] = mapped_column(String(255))
|
lti_deployment_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
title: Mapped[str | None] = mapped_column(String(255))
|
title: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
|
||||||
|
# Enough platform detail to call NRPS outside a launch, for the
|
||||||
|
# on-demand roster refresh. Captured/updated on each launch.
|
||||||
|
lti_issuer: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
lti_client_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
nrps_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
|
||||||
|
# Course term dates from Canvas ($Canvas.course.startAt/endAt
|
||||||
|
# custom parameters), used to bound date pickers. Nullable: not
|
||||||
|
# every course has them set in Canvas.
|
||||||
|
start_date: Mapped[datetime.date | None] = mapped_column(Date)
|
||||||
|
end_date: Mapped[datetime.date | None] = mapped_column(Date)
|
||||||
|
|
||||||
# Per-course settings.
|
# Per-course settings.
|
||||||
weight_factor: Mapped[float] = mapped_column(Float, default=2.0)
|
weight_factor: Mapped[float] = mapped_column(Float, default=2.0)
|
||||||
selection_mode: Mapped[str] = mapped_column(
|
selection_mode: Mapped[str] = mapped_column(
|
||||||
@@ -70,6 +90,7 @@ class Student(Base):
|
|||||||
name: Mapped[str | None] = mapped_column(String(255))
|
name: Mapped[str | None] = mapped_column(String(255))
|
||||||
sortable_name: Mapped[str | None] = mapped_column(String(255))
|
sortable_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
email: Mapped[str | None] = mapped_column(String(255))
|
email: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
pronouns: Mapped[str | None] = mapped_column(String(64))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
|
||||||
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
|
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="student")
|
||||||
@@ -90,6 +111,50 @@ class Enrollment(Base):
|
|||||||
student: Mapped[Student] = relationship(back_populates="enrollments")
|
student: Mapped[Student] = relationship(back_populates="enrollments")
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentLevel(Base):
|
||||||
|
"""One rung of a course's assessment scale: a label and its points
|
||||||
|
out of 100. Calls reference levels by id, so renaming a level
|
||||||
|
renames it everywhere, past calls included."""
|
||||||
|
|
||||||
|
__tablename__ = "assessment_levels"
|
||||||
|
__table_args__ = (UniqueConstraint("course_id", "label"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"))
|
||||||
|
label: Mapped[str] = mapped_column(String(64))
|
||||||
|
points: Mapped[float] = mapped_column(Float)
|
||||||
|
position: Mapped[int] = mapped_column(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_levels(session, course):
|
||||||
|
existing = session.execute(
|
||||||
|
select(AssessmentLevel.id)
|
||||||
|
.where(AssessmentLevel.course_id == course.id)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
for i, (label, points) in enumerate(DEFAULT_ASSESSMENT_LEVELS):
|
||||||
|
session.add(
|
||||||
|
AssessmentLevel(
|
||||||
|
course_id=course.id, label=label, points=points, position=i
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def assessment_levels(session, course_id):
|
||||||
|
return (
|
||||||
|
session.execute(
|
||||||
|
select(AssessmentLevel)
|
||||||
|
.where(AssessmentLevel.course_id == course_id)
|
||||||
|
.order_by(AssessmentLevel.position, AssessmentLevel.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClassDay(Base):
|
class ClassDay(Base):
|
||||||
"""A day the class actually meets. When a course has these, the
|
"""A day the class actually meets. When a course has these, the
|
||||||
student opt-out form only offers real class days; without them it
|
student opt-out form only offers real class days; without them it
|
||||||
@@ -124,13 +189,20 @@ class Call(Base):
|
|||||||
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
|
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"))
|
||||||
session_date: Mapped[datetime.date] = mapped_column(Date)
|
session_date: Mapped[datetime.date] = mapped_column(Date)
|
||||||
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING)
|
status: Mapped[str] = mapped_column(String(16), default=STATUS_PENDING)
|
||||||
assessment: Mapped[str | None] = mapped_column(String(32))
|
assessment_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("assessment_levels.id")
|
||||||
|
)
|
||||||
note: Mapped[str | None] = mapped_column(Text)
|
note: 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
|
||||||
)
|
)
|
||||||
|
|
||||||
student: Mapped[Student] = relationship()
|
student: Mapped[Student] = relationship()
|
||||||
|
level: Mapped["AssessmentLevel | None"] = relationship()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def assessment(self):
|
||||||
|
return self.level.label if self.level else None
|
||||||
|
|
||||||
|
|
||||||
def answered_call_counts(session, course_id):
|
def answered_call_counts(session, course_id):
|
||||||
|
|||||||
123
coldcall_lti/reports.py
Normal file
123
coldcall_lti/reports.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Data assembly for the instructor reports and CSV exports.
|
||||||
|
|
||||||
|
The fairness numbers follow the old track_participation.R /
|
||||||
|
compute_final_case_grades.R logic: a student's "questions present for"
|
||||||
|
is the sum of resolved calls on each day they were not opted out, and
|
||||||
|
prop_asked is their answered calls over that denominator. Resolved
|
||||||
|
means answered or missing; skipped and pending calls count nowhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
STATUS_ANSWERED,
|
||||||
|
STATUS_MISSING,
|
||||||
|
Call,
|
||||||
|
Enrollment,
|
||||||
|
OptOut,
|
||||||
|
Student,
|
||||||
|
)
|
||||||
|
|
||||||
|
RESOLVED = (STATUS_ANSWERED, STATUS_MISSING)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_calls(db, course_id):
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(Call)
|
||||||
|
.where(Call.course_id == course_id, Call.status.in_(RESOLVED))
|
||||||
|
.order_by(Call.session_date, Call.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def per_day_question_counts(db, course_id):
|
||||||
|
counts = Counter()
|
||||||
|
for call in _resolved_calls(db, course_id):
|
||||||
|
counts[call.session_date] += 1
|
||||||
|
return dict(counts)
|
||||||
|
|
||||||
|
|
||||||
|
def student_stats(db, course):
|
||||||
|
"""One row per active student: call counts, opt-outs, unreported
|
||||||
|
absences, and the fairness ratio."""
|
||||||
|
enrollments = db.execute(
|
||||||
|
select(Enrollment, Student)
|
||||||
|
.join(Student, 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)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
answered = Counter()
|
||||||
|
missing = Counter()
|
||||||
|
for call in _resolved_calls(db, course.id):
|
||||||
|
if call.status == STATUS_ANSWERED:
|
||||||
|
answered[call.student_id] += 1
|
||||||
|
else:
|
||||||
|
missing[call.student_id] += 1
|
||||||
|
|
||||||
|
optout_dates = defaultdict(set)
|
||||||
|
for optout in db.execute(
|
||||||
|
select(OptOut).where(OptOut.course_id == course.id)
|
||||||
|
).scalars():
|
||||||
|
optout_dates[optout.student_id].add(optout.date)
|
||||||
|
|
||||||
|
day_counts = per_day_question_counts(db, course.id)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for enrollment, student in enrollments:
|
||||||
|
q_present = sum(
|
||||||
|
n
|
||||||
|
for day, n in day_counts.items()
|
||||||
|
if day not in optout_dates[student.id]
|
||||||
|
)
|
||||||
|
n_answered = answered[student.id]
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"student": student,
|
||||||
|
"answered": n_answered,
|
||||||
|
"missing": missing[student.id],
|
||||||
|
"optouts": len(optout_dates[student.id]),
|
||||||
|
"questions_present": q_present,
|
||||||
|
"prop_asked": (n_answered / q_present) if q_present else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def assessments_by_day(db, course_id):
|
||||||
|
"""Per class day, how the resolved calls broke down: each
|
||||||
|
assessment plus 'missing'. Ordered by date."""
|
||||||
|
by_day = defaultdict(Counter)
|
||||||
|
for call in _resolved_calls(db, course_id):
|
||||||
|
if call.status == STATUS_MISSING:
|
||||||
|
by_day[call.session_date]["missing"] += 1
|
||||||
|
else:
|
||||||
|
by_day[call.session_date][call.assessment or "(unassessed)"] += 1
|
||||||
|
return [
|
||||||
|
{"day": day, "counts": dict(counts), "total": sum(counts.values())}
|
||||||
|
for day, counts in sorted(by_day.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def histogram(values):
|
||||||
|
"""Bins covering 0..max(values) as {value, students} dicts, plus
|
||||||
|
the largest bin size, for the HTML/CSS column charts."""
|
||||||
|
if not values:
|
||||||
|
return {"bins": [], "max_students": 1}
|
||||||
|
bins = Counter(values)
|
||||||
|
return {
|
||||||
|
"bins": [
|
||||||
|
{"value": k, "students": bins.get(k, 0)}
|
||||||
|
for k in range(0, max(values) + 1)
|
||||||
|
],
|
||||||
|
"max_students": max(bins.values()),
|
||||||
|
}
|
||||||
@@ -7,9 +7,24 @@ dev-mode fake roster.
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from .launch import CLAIM_CUSTOM, clean_custom_value
|
||||||
from .models import Enrollment, Student
|
from .models import Enrollment, Student
|
||||||
|
|
||||||
|
|
||||||
|
def _member_pronouns(member):
|
||||||
|
"""Canvas delivers pronouns through the per-member message array's
|
||||||
|
custom claim (requires the tool's custom parameter
|
||||||
|
pronouns=$com.instructure.Person.pronouns and an rlid-scoped
|
||||||
|
membership request). Dev mode uses a plain 'pronouns' key."""
|
||||||
|
if member.get("pronouns"):
|
||||||
|
return clean_custom_value(member["pronouns"])
|
||||||
|
for message in member.get("message", []):
|
||||||
|
value = message.get(CLAIM_CUSTOM, {}).get("pronouns")
|
||||||
|
if clean_custom_value(value):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _member_role(roles):
|
def _member_role(roles):
|
||||||
return "instructor" if any("Instructor" in r for r in roles) else "student"
|
return "instructor" if any("Instructor" in r for r in roles) else "student"
|
||||||
|
|
||||||
@@ -49,6 +64,9 @@ def sync_roster(db, course, members):
|
|||||||
student.email = member["email"]
|
student.email = member["email"]
|
||||||
if member.get("picture"):
|
if member.get("picture"):
|
||||||
student.avatar_url = member["picture"]
|
student.avatar_url = member["picture"]
|
||||||
|
pronouns = _member_pronouns(member)
|
||||||
|
if pronouns:
|
||||||
|
student.pronouns = pronouns
|
||||||
db.flush()
|
db.flush()
|
||||||
seen_student_ids.add(student.id)
|
seen_student_ids.add(student.id)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
.muted { color: #666; }
|
.muted { color: #666; }
|
||||||
.callcard { margin: 1.5rem 0; }
|
.callcard { margin: 1.5rem 0; }
|
||||||
.callname { font-size: 2.25rem; font-weight: bold; margin: 0.5rem 0 0; }
|
.callname { font-size: 2.25rem; font-weight: bold; margin: 0.5rem 0 0; }
|
||||||
|
.pronouns { font-size: 1.25rem; font-weight: normal; color: #555; }
|
||||||
button { padding: 0.4rem 0.8rem; margin: 0.15rem; }
|
button { padding: 0.4rem 0.8rem; margin: 0.15rem; }
|
||||||
button.big { font-size: 1.5rem; padding: 1rem 2rem; }
|
button.big { font-size: 1.5rem; padding: 1rem 2rem; }
|
||||||
.printlist td { border-bottom: 1px solid #ccc; padding: 0.5rem 0.75rem 0.5rem 0; }
|
.printlist td { border-bottom: 1px solid #ccc; padding: 0.5rem 0.75rem 0.5rem 0; }
|
||||||
@@ -22,6 +23,15 @@
|
|||||||
.hist .bar.me { background: #d9822b; }
|
.hist .bar.me { background: #d9822b; }
|
||||||
.hist .bar-n { font-size: 0.75rem; }
|
.hist .bar-n { font-size: 0.75rem; }
|
||||||
.hist .bar-x { font-size: 0.8rem; }
|
.hist .bar-x { font-size: 0.8rem; }
|
||||||
|
.stack { display: flex; height: 1.1rem; min-width: 12rem; }
|
||||||
|
.daybars td { padding-right: 0.6rem; }
|
||||||
|
.key { display: inline-block; width: 0.9rem; height: 0.9rem; vertical-align: middle; }
|
||||||
|
.o-good { background: #4a7c59; }
|
||||||
|
.o-satisfactory { background: #7a9cc6; }
|
||||||
|
.o-poor { background: #d9a53f; }
|
||||||
|
.o-no-meaningful-answer { background: #b5535a; }
|
||||||
|
.o-unassessed { background: #cfcfcf; }
|
||||||
|
.o-missing { background: #777; }
|
||||||
@media print {
|
@media print {
|
||||||
.no-print { display: none; }
|
.no-print { display: none; }
|
||||||
body { margin: 0.5rem; max-width: none; }
|
body { margin: 0.5rem; max-width: none; }
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<td>
|
<td>
|
||||||
<select name="assessment-{{ c.id }}">
|
<select name="assessment-{{ c.id }}">
|
||||||
<option value=""></option>
|
<option value=""></option>
|
||||||
{% for a in assessments %}
|
{% for lvl in levels %}
|
||||||
<option value="{{ a }}" {{ "selected" if c.assessment == a }}>{{ a }}</option>
|
<option value="{{ lvl.id }}" {{ "selected" if c.assessment_id == lvl.id }}>{{ lvl.label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -10,10 +10,17 @@
|
|||||||
<p>
|
<p>
|
||||||
<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.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>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('instructor.roster_sync') }}">
|
||||||
|
<button type="submit">Sync roster from Canvas now</button>
|
||||||
|
<span class="muted">The roster also refreshes automatically every
|
||||||
|
time you open this tool from Canvas.</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
<h2>Printable list</h2>
|
<h2>Printable list</h2>
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}">
|
action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}">
|
||||||
|
|||||||
@@ -13,15 +13,19 @@
|
|||||||
{% if call.student.avatar_url %}
|
{% if call.student.avatar_url %}
|
||||||
<img src="{{ call.student.avatar_url }}" alt="" width="120">
|
<img src="{{ call.student.avatar_url }}" alt="" width="120">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="callname">{{ call.student.name }}</p>
|
<p class="callname">{{ call.student.name }}
|
||||||
|
{% if call.student.pronouns %}
|
||||||
|
<span class="pronouns">({{ call.student.pronouns }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
{% if call.student.sortable_name %}
|
{% if call.student.sortable_name %}
|
||||||
<p class="muted">{{ call.student.sortable_name }}</p>
|
<p class="muted">{{ call.student.sortable_name }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="{{ url_for('instructor.call_outcome', call_id=call.id) }}">
|
action="{{ url_for('instructor.call_outcome', call_id=call.id) }}">
|
||||||
{% for a in assessments %}
|
{% for lvl in levels %}
|
||||||
<button type="submit" name="action" value="{{ a }}">{{ a }}</button>
|
<button type="submit" name="action" value="level-{{ lvl.id }}">{{ lvl.label }}</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button type="submit" name="action" value="missing">Missing</button>
|
<button type="submit" name="action" value="missing">Missing</button>
|
||||||
<button type="submit" name="action" value="skip">Skip</button>
|
<button type="submit" name="action" value="skip">Skip</button>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ c.student.name }}
|
{{ c.student.name }}
|
||||||
|
{% if c.student.pronouns %}
|
||||||
|
<span class="muted">{{ c.student.pronouns }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if c.student.sortable_name and c.student.sortable_name != c.student.name %}
|
{% if c.student.sortable_name and c.student.sortable_name != c.student.name %}
|
||||||
<span class="muted">({{ c.student.sortable_name }})</span>
|
<span class="muted">({{ c.student.sortable_name }})</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
104
coldcall_lti/templates/report.html
Normal file
104
coldcall_lti/templates/report.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Report — {{ course.title or "Cold Call" }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p class="no-print"><a href="{{ url_for('instructor.home') }}">← back</a></p>
|
||||||
|
<h1>Participation report</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ total_questions }} questions asked over {{ by_day|length }}
|
||||||
|
class day{{ "s" if by_day|length != 1 }}.
|
||||||
|
{% for k in outcome_order if outcome_totals.get(k) %}
|
||||||
|
{{ outcome_totals[k] }} {{ k }}{{ "," if not loop.last else "." }}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Questions answered per student</h2>
|
||||||
|
<div class="hist">
|
||||||
|
{% for bin in calls_hist.bins %}
|
||||||
|
<div class="bin">
|
||||||
|
<div class="bar-n muted">{{ bin.students or "" }}</div>
|
||||||
|
<div class="bar" style="height: {{ (bin.students / calls_hist.max_students * 100) | round }}%"></div>
|
||||||
|
<div class="bar-x">{{ bin.value }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">Students by number of questions answered.</p>
|
||||||
|
|
||||||
|
<h2>Opt-outs per student</h2>
|
||||||
|
<div class="hist">
|
||||||
|
{% for bin in optouts_hist.bins %}
|
||||||
|
<div class="bin">
|
||||||
|
<div class="bar-n muted">{{ bin.students or "" }}</div>
|
||||||
|
<div class="bar" style="height: {{ (bin.students / optouts_hist.max_students * 100) | round }}%"></div>
|
||||||
|
<div class="bar-x">{{ bin.value }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">Students by number of opt-outs.</p>
|
||||||
|
|
||||||
|
{% if by_day %}
|
||||||
|
<h2>Outcomes by class day</h2>
|
||||||
|
<table class="daybars">
|
||||||
|
{% for d in by_day %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">{{ d.day.isoformat() }}</td>
|
||||||
|
<td class="muted">{{ d.total }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="stack">
|
||||||
|
{% for k in outcome_order if d.counts.get(k) %}
|
||||||
|
<div class="seg seg-{{ loop.index0 }} o-{{ k | lower | replace(' ', '-') | replace('(', '') | replace(')', '') }}"
|
||||||
|
style="width: {{ (d.counts[k] / d.total * 100) | round(1) }}%"
|
||||||
|
title="{{ k }}: {{ d.counts[k] }}"></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<p class="muted">
|
||||||
|
{% for k in outcome_order if outcome_totals.get(k) %}
|
||||||
|
<span class="key o-{{ k | lower | replace(' ', '-') | replace('(', '') | replace(')', '') }}"></span> {{ k }}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Students</h2>
|
||||||
|
<p class="no-print muted">
|
||||||
|
Sort by:
|
||||||
|
<a href="{{ url_for('.report', sort='name') }}">name</a> ·
|
||||||
|
<a href="{{ url_for('.report', sort='answered') }}">answered</a> ·
|
||||||
|
<a href="{{ url_for('.report', sort='missing') }}">missing</a> ·
|
||||||
|
<a href="{{ url_for('.report', sort='optouts') }}">opt-outs</a> ·
|
||||||
|
<a href="{{ url_for('.report', sort='prop') }}">share asked</a>
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Student</th><th>Answered</th><th>Missing</th>
|
||||||
|
<th>Opt-outs</th><th>Share asked</th><th></th>
|
||||||
|
</tr>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.student.name }}</td>
|
||||||
|
<td>{{ r.answered }}</td>
|
||||||
|
<td>{{ r.missing or "" }}</td>
|
||||||
|
<td>{{ r.optouts or "" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if r.prop_asked is not none %}
|
||||||
|
{{ "%.1f" | format(r.prop_asked * 100) }}%
|
||||||
|
<span class="muted">of {{ r.questions_present }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="muted">
|
||||||
|
{% if r.answered == 0 and r.missing == 0 %}never called{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="no-print">
|
||||||
|
Export:
|
||||||
|
<a href="{{ url_for('.export_students') }}">students.csv</a> ·
|
||||||
|
<a href="{{ url_for('.export_calls') }}">calls.csv</a> ·
|
||||||
|
<a href="{{ url_for('.export_optouts') }}">optouts.csv</a>
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,10 +7,14 @@
|
|||||||
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.</p>
|
||||||
|
|
||||||
|
{% set dmin = course.start_date.isoformat() if course.start_date else "" %}
|
||||||
|
{% set dmax = course.end_date.isoformat() if course.end_date else "" %}
|
||||||
<h2>Add a range</h2>
|
<h2>Add a range</h2>
|
||||||
<form method="post" action="{{ url_for('instructor.schedule_generate') }}">
|
<form method="post" action="{{ url_for('instructor.schedule_generate') }}">
|
||||||
<label>From <input type="date" name="start" required></label>
|
<label>From <input type="date" name="start" required
|
||||||
<label>to <input type="date" name="end" required></label>
|
value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||||
|
<label>to <input type="date" name="end" required
|
||||||
|
value="{{ dmax }}" min="{{ dmin }}" max="{{ dmax }}"></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 %}
|
||||||
@@ -19,7 +23,8 @@
|
|||||||
|
|
||||||
<h2>Add a single day</h2>
|
<h2>Add a single day</h2>
|
||||||
<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>
|
<label>Date <input type="date" name="date" required
|
||||||
|
min="{{ dmin }}" max="{{ dmax }}"></label>
|
||||||
<button type="submit">Add</button>
|
<button type="submit">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,40 @@
|
|||||||
{{ "checked" if course.show_assessments }}>
|
{{ "checked" if course.show_assessments }}>
|
||||||
Students can see their own assessments</label>
|
Students can see their own assessments</label>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>Assessment scale</h2>
|
||||||
|
<p class="muted">Each level has a label and points out of 100, used
|
||||||
|
when computing participation grades. Renaming a level renames it on
|
||||||
|
every past call too; levels already used by recorded calls cannot
|
||||||
|
be deleted. Lower position numbers sort first.</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Position</th><th>Label</th><th>Points</th><th>Delete</th></tr>
|
||||||
|
{% for lvl in levels %}
|
||||||
|
<tr>
|
||||||
|
<td><input type="number" name="position-{{ lvl.id }}"
|
||||||
|
value="{{ lvl.position }}" style="width: 4rem"></td>
|
||||||
|
<td><input type="text" name="label-{{ lvl.id }}" value="{{ lvl.label }}"></td>
|
||||||
|
<td><input type="number" name="points-{{ lvl.id }}" step="0.01"
|
||||||
|
min="0" max="100" value="{{ lvl.points }}"></td>
|
||||||
|
<td>
|
||||||
|
{% if level_use.get(lvl.id) %}
|
||||||
|
<span class="muted">in use ({{ level_use[lvl.id] }}
|
||||||
|
call{{ "s" if level_use[lvl.id] != 1 }})</span>
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" name="delete-{{ lvl.id }}">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr>
|
||||||
|
<td class="muted">new</td>
|
||||||
|
<td><input type="text" name="new_label" placeholder="Label"></td>
|
||||||
|
<td><input type="number" name="new_points" step="0.01"
|
||||||
|
min="0" max="100" placeholder="Points"></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<p><button type="submit">Save</button></p>
|
<p><button type="submit">Save</button></p>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
{% block title %}{{ course.title or "Cold Call" }}{% endblock %}
|
{% block title %}{{ course.title or "Cold Call" }}{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1>{{ course.title or course.lti_context_id }}</h1>
|
<h1>{{ course.title or course.lti_context_id }}</h1>
|
||||||
<p>Hi {{ user.name }}.</p>
|
<p>Hi {{ user.name }}{% if user.pronouns %} ({{ user.pronouns }}){% endif %}.</p>
|
||||||
|
<p class="muted">Your name and pronouns here are drawn from Canvas. If
|
||||||
|
they are not right, update them in your Canvas account settings and
|
||||||
|
they will be picked up here automatically.</p>
|
||||||
|
|
||||||
<h2>Your standing</h2>
|
<h2>Your standing</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ def run_migrations_offline() -> None:
|
|||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
# SQLite cannot ALTER constraints in place; batch mode uses the
|
||||||
|
# copy-and-move strategy and is harmless on MariaDB/MySQL.
|
||||||
|
render_as_batch=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
@@ -70,7 +73,9 @@ def run_migrations_online() -> None:
|
|||||||
|
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
context.configure(
|
context.configure(
|
||||||
connection=connection, target_metadata=target_metadata
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
render_as_batch=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
|
|||||||
30
migrations/versions/1ec230be46bd_student_pronouns.py
Normal file
30
migrations/versions/1ec230be46bd_student_pronouns.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""student pronouns
|
||||||
|
|
||||||
|
Revision ID: 1ec230be46bd
|
||||||
|
Revises: 37acdb847a25
|
||||||
|
Create Date: 2026-07-31 16:47:53.477882
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '1ec230be46bd'
|
||||||
|
down_revision: Union[str, None] = '37acdb847a25'
|
||||||
|
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.add_column('students', sa.Column('pronouns', sa.String(length=64), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('students', 'pronouns')
|
||||||
|
# ### end Alembic commands ###
|
||||||
32
migrations/versions/487d155678ea_course_term_dates.py
Normal file
32
migrations/versions/487d155678ea_course_term_dates.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""course term dates
|
||||||
|
|
||||||
|
Revision ID: 487d155678ea
|
||||||
|
Revises: 1ec230be46bd
|
||||||
|
Create Date: 2026-07-31 16:50:52.938563
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '487d155678ea'
|
||||||
|
down_revision: Union[str, None] = '1ec230be46bd'
|
||||||
|
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.add_column('courses', sa.Column('start_date', sa.Date(), nullable=True))
|
||||||
|
op.add_column('courses', sa.Column('end_date', sa.Date(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('courses', 'end_date')
|
||||||
|
op.drop_column('courses', 'start_date')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""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 ###
|
||||||
@@ -32,3 +32,17 @@ def make_app(tmp_path, dev_mode=True):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dev_client(tmp_path):
|
def dev_client(tmp_path):
|
||||||
return make_app(tmp_path, dev_mode=True).test_client()
|
return make_app(tmp_path, dev_mode=True).test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def outcome_actions(page):
|
||||||
|
"""Map outcome button labels to their form values on a live page,
|
||||||
|
e.g. {'GOOD': 'level-1', ..., 'Missing': 'missing'}."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
return {
|
||||||
|
label.strip(): value
|
||||||
|
for value, label in re.findall(
|
||||||
|
r'<button type="submit" name="action" value="([^"]+)">([^<]+)</button>',
|
||||||
|
page,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
30
tests/test_cli.py
Normal file
30
tests/test_cli.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from coldcall_lti.models import Course
|
||||||
|
from conftest import make_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_rosters_skips_unsyncable_and_ended_courses(tmp_path):
|
||||||
|
app = make_app(tmp_path, dev_mode=True)
|
||||||
|
client = app.test_client()
|
||||||
|
client.post("/dev/launch/dev-instructor") # creates the dev course
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db = app.extensions["db_session_factory"]()
|
||||||
|
ended = Course(
|
||||||
|
lti_context_id="old-course",
|
||||||
|
title="Old Course",
|
||||||
|
nrps_url="https://example.edu/nrps",
|
||||||
|
end_date=datetime.date.today() - datetime.timedelta(days=90),
|
||||||
|
)
|
||||||
|
db.add(ended)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
result = app.test_cli_runner().invoke(args=["sync-rosters"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
# The dev course has no NRPS service; the old course has ended.
|
||||||
|
assert "no roster service recorded, skipped" in result.output
|
||||||
|
assert "Old Course: ended, skipped" in result.output
|
||||||
@@ -3,6 +3,8 @@ import re
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from conftest import outcome_actions
|
||||||
|
|
||||||
TODAY = datetime.date.today().isoformat()
|
TODAY = datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ def test_live_flow_records_outcome(instructor):
|
|||||||
call_id = match.group(1)
|
call_id = match.group(1)
|
||||||
resp = instructor.post(
|
resp = instructor.post(
|
||||||
f"/instructor/call/{call_id}/outcome",
|
f"/instructor/call/{call_id}/outcome",
|
||||||
data={"action": "GOOD"},
|
data={"action": outcome_actions(page)["GOOD"]},
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
page = resp.get_data(as_text=True)
|
page = resp.get_data(as_text=True)
|
||||||
@@ -76,12 +78,13 @@ def test_day_edit_saves_outcomes(instructor):
|
|||||||
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||||
ids = re.findall(r'name="status-(\d+)"', page)
|
ids = re.findall(r'name="status-(\d+)"', page)
|
||||||
assert len(ids) == 2
|
assert len(ids) == 2
|
||||||
|
poor_id = re.search(r'value="(\d+)"[^>]*>POOR</option>', page).group(1)
|
||||||
|
|
||||||
resp = instructor.post(
|
resp = instructor.post(
|
||||||
f"/instructor/day/{TODAY}",
|
f"/instructor/day/{TODAY}",
|
||||||
data={
|
data={
|
||||||
f"status-{ids[0]}": "answered",
|
f"status-{ids[0]}": "answered",
|
||||||
f"assessment-{ids[0]}": "POOR",
|
f"assessment-{ids[0]}": poor_id,
|
||||||
f"note-{ids[0]}": "rough day",
|
f"note-{ids[0]}": "rough day",
|
||||||
f"status-{ids[1]}": "pending",
|
f"status-{ids[1]}": "pending",
|
||||||
f"delete-{ids[1]}": "on",
|
f"delete-{ids[1]}": "on",
|
||||||
@@ -103,11 +106,56 @@ def test_outcome_rejects_bad_action_and_foreign_call(instructor):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
resp = instructor.post(
|
resp = instructor.post(
|
||||||
"/instructor/call/99999/outcome", data={"action": "GOOD"}
|
"/instructor/call/99999/outcome", data={"action": "missing"}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_assessment_scale_editing(instructor):
|
||||||
|
page = instructor.get("/instructor/settings").get_data(as_text=True)
|
||||||
|
ids = {
|
||||||
|
label: lid
|
||||||
|
for lid, label in re.findall(
|
||||||
|
r'name="label-(\d+)" value="([^"]+)"', page
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert set(ids) == {"GOOD", "SATISFACTORY", "POOR", "NO MEANINGFUL ANSWER"}
|
||||||
|
|
||||||
|
# Record a call assessed GOOD so that level is in use.
|
||||||
|
record = instructor.post(
|
||||||
|
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
|
||||||
|
).get_data(as_text=True)
|
||||||
|
call_id = re.search(r"/instructor/call/(\d+)/outcome", record).group(1)
|
||||||
|
instructor.post(
|
||||||
|
f"/instructor/call/{call_id}/outcome",
|
||||||
|
data={"action": f"level-{ids['GOOD']}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rename and re-point the in-use level; delete an unused one; add one.
|
||||||
|
form = {"selection_mode": "weighted", "weight_factor": "2",
|
||||||
|
"show_assessments": "on",
|
||||||
|
"new_label": "HEROIC", "new_points": "110"}
|
||||||
|
for label, lid in ids.items():
|
||||||
|
form[f"label-{lid}"] = "EXCELLENT" if label == "GOOD" else label
|
||||||
|
form[f"points-{lid}"] = "90" if label == "GOOD" else "50"
|
||||||
|
form[f"position-{lid}"] = "0"
|
||||||
|
form[f"delete-{ids['POOR']}"] = "on"
|
||||||
|
form[f"delete-{ids['GOOD']}"] = "on" # in use: must survive
|
||||||
|
page = instructor.post(
|
||||||
|
"/instructor/settings", data=form, follow_redirects=True
|
||||||
|
).get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'value="EXCELLENT"' in page # renamed
|
||||||
|
assert 'value="90.0"' in page # re-pointed
|
||||||
|
assert "POOR" not in page # unused level deleted
|
||||||
|
assert re.search(r"in use \(1\s+call\)", page) # used level kept
|
||||||
|
assert "HEROIC" not in page # >100 points rejected
|
||||||
|
|
||||||
|
# The rename follows through to recorded calls.
|
||||||
|
day_page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||||
|
assert "EXCELLENT" in day_page
|
||||||
|
|
||||||
|
|
||||||
def test_settings_roundtrip(instructor):
|
def test_settings_roundtrip(instructor):
|
||||||
resp = instructor.post(
|
resp = instructor.post(
|
||||||
"/instructor/settings",
|
"/instructor/settings",
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ def test_extract_launch_info_instructor():
|
|||||||
assert info.is_instructor
|
assert info.is_instructor
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_launch_info_pronouns_custom_claim():
|
||||||
|
data = sample_launch_data()
|
||||||
|
data[launch.CLAIM_CUSTOM] = {"pronouns": "ze/zir"}
|
||||||
|
assert launch.extract_launch_info(data).pronouns == "ze/zir"
|
||||||
|
|
||||||
|
data[launch.CLAIM_CUSTOM] = {"pronouns": "$com.instructure.Person.pronouns"}
|
||||||
|
assert launch.extract_launch_info(data).pronouns is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_launch_info_course_dates():
|
||||||
|
data = sample_launch_data()
|
||||||
|
data[launch.CLAIM_CUSTOM] = {
|
||||||
|
"course_start": "2026-09-30T07:00:00Z",
|
||||||
|
"course_end": "$Canvas.course.endAt",
|
||||||
|
}
|
||||||
|
info = launch.extract_launch_info(data)
|
||||||
|
assert info.course_start == launch.datetime.date(2026, 9, 30)
|
||||||
|
assert info.course_end is None
|
||||||
|
|
||||||
|
|
||||||
def test_upsert_launch_creates_and_is_idempotent(db_session):
|
def test_upsert_launch_creates_and_is_idempotent(db_session):
|
||||||
info = launch.extract_launch_info(sample_launch_data())
|
info = launch.extract_launch_info(sample_launch_data())
|
||||||
course, student = launch.upsert_launch(db_session, info)
|
course, student = launch.upsert_launch(db_session, info)
|
||||||
|
|||||||
81
tests/test_report_ui.py
Normal file
81
tests/test_report_ui.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import outcome_actions
|
||||||
|
|
||||||
|
TODAY = datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def instructor(dev_client):
|
||||||
|
dev_client.post("/dev/launch/dev-instructor")
|
||||||
|
return dev_client
|
||||||
|
|
||||||
|
|
||||||
|
def record_one_call(client, action="GOOD"):
|
||||||
|
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)
|
||||||
|
actions = outcome_actions(page)
|
||||||
|
value = actions.get(action) or actions[action.title()]
|
||||||
|
client.post(f"/instructor/call/{call_id}/outcome", data={"action": value})
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_page_renders(instructor):
|
||||||
|
record_one_call(instructor, "GOOD")
|
||||||
|
record_one_call(instructor, "POOR")
|
||||||
|
record_one_call(instructor, "missing")
|
||||||
|
|
||||||
|
page = instructor.get("/instructor/report").get_data(as_text=True)
|
||||||
|
assert "3 questions asked over 1" in page
|
||||||
|
assert "never called" in page
|
||||||
|
assert 'class="hist"' in page
|
||||||
|
assert "Outcomes by class day" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_sorting(instructor):
|
||||||
|
record_one_call(instructor, "GOOD")
|
||||||
|
page = instructor.get("/instructor/report?sort=answered").get_data(as_text=True)
|
||||||
|
rows = re.findall(r"<td>([A-Z][a-z]+ [A-Za-z]+)</td>", page)
|
||||||
|
# The called student sorts first under the answered sort.
|
||||||
|
called = re.search(r"never called", page)
|
||||||
|
assert rows, "expected student rows"
|
||||||
|
assert called
|
||||||
|
|
||||||
|
|
||||||
|
def test_csv_exports(instructor):
|
||||||
|
record_one_call(instructor, "GOOD")
|
||||||
|
resp = instructor.get("/instructor/export/students.csv")
|
||||||
|
assert resp.mimetype == "text/csv"
|
||||||
|
lines = resp.get_data(as_text=True).strip().splitlines()
|
||||||
|
assert lines[0].startswith("name,")
|
||||||
|
assert len(lines) == 9 # header + 8 students
|
||||||
|
|
||||||
|
resp = instructor.get("/instructor/export/calls.csv")
|
||||||
|
lines = resp.get_data(as_text=True).strip().splitlines()
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert ",answered,GOOD," in lines[1]
|
||||||
|
|
||||||
|
resp = instructor.get("/instructor/export/optouts.csv")
|
||||||
|
assert resp.get_data(as_text=True).strip().splitlines()[0].startswith("date,")
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_requires_instructor(dev_client):
|
||||||
|
dev_client.post("/dev/launch/dev-instructor")
|
||||||
|
dev_client.post("/dev/launch/dev-student-1")
|
||||||
|
assert dev_client.get("/instructor/report").status_code == 403
|
||||||
|
assert dev_client.get("/instructor/export/calls.csv").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_pronouns_flow_to_pages(instructor):
|
||||||
|
# Dev roster carries pronouns; they should reach the print list.
|
||||||
|
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "30"})
|
||||||
|
page = instructor.get(f"/instructor/day/{TODAY}/print").get_data(as_text=True)
|
||||||
|
assert "she/her" in page or "he/him" in page
|
||||||
|
|
||||||
|
instructor.post("/dev/launch/dev-student-1")
|
||||||
|
page = instructor.get("/me").get_data(as_text=True)
|
||||||
|
assert "Ada Lovelace (she/her)" in page
|
||||||
|
assert "drawn from Canvas" in page
|
||||||
95
tests/test_reports.py
Normal file
95
tests/test_reports.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from coldcall_lti import models, reports
|
||||||
|
|
||||||
|
D1 = datetime.date(2026, 10, 1)
|
||||||
|
D2 = datetime.date(2026, 10, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_course(db):
|
||||||
|
course = models.Course(lti_context_id="ctx-1")
|
||||||
|
db.add(course)
|
||||||
|
db.flush()
|
||||||
|
models.ensure_default_levels(db, course)
|
||||||
|
students = []
|
||||||
|
for i in range(3):
|
||||||
|
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()
|
||||||
|
return course, students
|
||||||
|
|
||||||
|
|
||||||
|
def add_call(db, course, student, day, status, assessment=None):
|
||||||
|
level_ids = {
|
||||||
|
lvl.label: lvl.id
|
||||||
|
for lvl in models.assessment_levels(db, course.id)
|
||||||
|
}
|
||||||
|
db.add(
|
||||||
|
models.Call(
|
||||||
|
course_id=course.id,
|
||||||
|
student_id=student.id,
|
||||||
|
session_date=day,
|
||||||
|
status=status,
|
||||||
|
assessment_id=level_ids[assessment] if assessment else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_student_stats_fairness_denominator(db_session):
|
||||||
|
course, (a, b, c) = setup_course(db_session)
|
||||||
|
# Day 1: three questions; day 2: one question. b opted out day 1.
|
||||||
|
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "GOOD")
|
||||||
|
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "POOR")
|
||||||
|
add_call(db_session, course, c, D1, models.STATUS_MISSING)
|
||||||
|
add_call(db_session, course, b, D2, models.STATUS_ANSWERED, "GOOD")
|
||||||
|
# Skipped and pending calls count nowhere.
|
||||||
|
add_call(db_session, course, c, D2, models.STATUS_SKIPPED)
|
||||||
|
add_call(db_session, course, c, D2, models.STATUS_PENDING)
|
||||||
|
db_session.add(
|
||||||
|
models.OptOut(course_id=course.id, student_id=b.id, date=D1)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
rows = {r["student"].id: r for r in reports.student_stats(db_session, course)}
|
||||||
|
|
||||||
|
assert rows[a.id]["answered"] == 2
|
||||||
|
assert rows[a.id]["questions_present"] == 4
|
||||||
|
assert rows[a.id]["prop_asked"] == 0.5
|
||||||
|
|
||||||
|
# b missed day 1's three questions by opting out.
|
||||||
|
assert rows[b.id]["questions_present"] == 1
|
||||||
|
assert rows[b.id]["prop_asked"] == 1.0
|
||||||
|
assert rows[b.id]["optouts"] == 1
|
||||||
|
|
||||||
|
assert rows[c.id]["answered"] == 0
|
||||||
|
assert rows[c.id]["missing"] == 1
|
||||||
|
assert rows[c.id]["prop_asked"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_assessments_by_day(db_session):
|
||||||
|
course, (a, b, c) = setup_course(db_session)
|
||||||
|
add_call(db_session, course, a, D1, models.STATUS_ANSWERED, "GOOD")
|
||||||
|
add_call(db_session, course, b, D1, models.STATUS_ANSWERED, "GOOD")
|
||||||
|
add_call(db_session, course, c, D1, models.STATUS_MISSING)
|
||||||
|
add_call(db_session, course, a, D2, models.STATUS_ANSWERED, "SATISFACTORY")
|
||||||
|
|
||||||
|
by_day = reports.assessments_by_day(db_session, course.id)
|
||||||
|
assert [d["day"] for d in by_day] == [D1, D2]
|
||||||
|
assert by_day[0]["counts"] == {"GOOD": 2, "missing": 1}
|
||||||
|
assert by_day[0]["total"] == 3
|
||||||
|
assert by_day[1]["counts"] == {"SATISFACTORY": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_histogram_includes_zero_bin():
|
||||||
|
h = reports.histogram([0, 0, 2])
|
||||||
|
assert h["bins"] == [
|
||||||
|
{"value": 0, "students": 2},
|
||||||
|
{"value": 1, "students": 0},
|
||||||
|
{"value": 2, "students": 1},
|
||||||
|
]
|
||||||
|
assert h["max_students"] == 2
|
||||||
@@ -74,6 +74,28 @@ def test_sync_skips_inactive_members(db_session):
|
|||||||
assert db_session.query(models.Student).count() == 1
|
assert db_session.query(models.Student).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_extracts_pronouns_from_nrps_message(db_session):
|
||||||
|
course = make_course(db_session)
|
||||||
|
custom_claim = "https://purl.imsglobal.org/spec/lti/claim/custom"
|
||||||
|
sync_roster(
|
||||||
|
db_session,
|
||||||
|
course,
|
||||||
|
[
|
||||||
|
member("u1", message=[{custom_claim: {"pronouns": "they/them"}}]),
|
||||||
|
# Unexpanded variable (platform without pronouns) is ignored.
|
||||||
|
member(
|
||||||
|
"u2",
|
||||||
|
message=[{custom_claim: {"pronouns": "$com.instructure.Person.pronouns"}}],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
students = {
|
||||||
|
s.canvas_user_id: s for s in db_session.query(models.Student).all()
|
||||||
|
}
|
||||||
|
assert students["u1"].pronouns == "they/them"
|
||||||
|
assert students["u2"].pronouns is None
|
||||||
|
|
||||||
|
|
||||||
def test_sync_updates_changed_names(db_session):
|
def test_sync_updates_changed_names(db_session):
|
||||||
course = make_course(db_session)
|
course = make_course(db_session)
|
||||||
sync_roster(db_session, course, [member("u1", "Old Name")])
|
sync_roster(db_session, course, [member("u1", "Old Name")])
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import re
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from conftest import outcome_actions
|
||||||
|
|
||||||
TODAY = datetime.date.today()
|
TODAY = datetime.date.today()
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +99,8 @@ def test_assessment_visibility_setting(student):
|
|||||||
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
|
call_id = re.search(r"/instructor/call/(\d+)/outcome", page).group(1)
|
||||||
target = "Ada Lovelace" in page
|
target = "Ada Lovelace" in page
|
||||||
student.post(
|
student.post(
|
||||||
f"/instructor/call/{call_id}/outcome", data={"action": "POOR"}
|
f"/instructor/call/{call_id}/outcome",
|
||||||
|
data={"action": outcome_actions(page)["POOR"]},
|
||||||
)
|
)
|
||||||
if target:
|
if target:
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user