Phase 3: instructor in-class UI
Live mode with resolve-before-next call flow and one-tap outcomes (assessments, missing, skip); printable numbered call lists with pictures and a blank notes column; a day editor for after-class outcome entry, replacing hand-editing of call_list TSVs; and a per-course settings page (selection mode, weight factor, assessment visibility). Selection honors opt-outs and the course's mode: weighted draws use full-course answered-call history, cycle mode calls everyone once per day before starting over and treats skips as never called. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,10 +23,11 @@ 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 lti, views
|
from . import 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)
|
||||||
|
|
||||||
if app.config["DEV_MODE"]:
|
if app.config["DEV_MODE"]:
|
||||||
from . import dev
|
from . import dev
|
||||||
|
|||||||
128
coldcall_lti/calls.py
Normal file
128
coldcall_lti/calls.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Database-aware call operations: picking the next student in live
|
||||||
|
mode, generating a day's call list, and managing pending calls.
|
||||||
|
|
||||||
|
Selection math lives in selection.py; this module feeds it from the
|
||||||
|
database and honors the course's selection mode and weight factor.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from . import selection
|
||||||
|
from .models import (
|
||||||
|
SELECTION_CYCLE,
|
||||||
|
STATUS_PENDING,
|
||||||
|
STATUS_SKIPPED,
|
||||||
|
Call,
|
||||||
|
answered_call_counts,
|
||||||
|
students_present,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calls_for_day(db, course_id, day):
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
select(Call)
|
||||||
|
.where(Call.course_id == course_id, Call.session_date == day)
|
||||||
|
.order_by(Call.created_at, Call.id)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unresolved_call(db, course_id, day):
|
||||||
|
"""The oldest pending call for the day, if any. Live mode requires
|
||||||
|
resolving it before drawing the next student."""
|
||||||
|
return db.execute(
|
||||||
|
select(Call)
|
||||||
|
.where(
|
||||||
|
Call.course_id == course_id,
|
||||||
|
Call.session_date == day,
|
||||||
|
Call.status == STATUS_PENDING,
|
||||||
|
)
|
||||||
|
.order_by(Call.created_at, Call.id)
|
||||||
|
).scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
def pick_next_student(db, course, day, rng=random):
|
||||||
|
"""Choose who to call next, honoring opt-outs and the course's
|
||||||
|
selection mode. Returns None if nobody is available."""
|
||||||
|
present = students_present(db, course.id, day)
|
||||||
|
if not present:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if course.selection_mode == SELECTION_CYCLE:
|
||||||
|
# Everyone once per day, in random order: draw from those not
|
||||||
|
# yet called today, starting over once the roster is exhausted.
|
||||||
|
called_today = {
|
||||||
|
c.student_id
|
||||||
|
for c in calls_for_day(db, course.id, day)
|
||||||
|
if c.status != STATUS_SKIPPED
|
||||||
|
}
|
||||||
|
pool = [s for s in present if s.id not in called_today] or present
|
||||||
|
return rng.choice(pool)
|
||||||
|
|
||||||
|
by_id = {s.id: s for s in present}
|
||||||
|
counts = answered_call_counts(db, course.id)
|
||||||
|
picked = selection.select_student(
|
||||||
|
list(by_id), counts, course.weight_factor, rng
|
||||||
|
)
|
||||||
|
return by_id[picked]
|
||||||
|
|
||||||
|
|
||||||
|
def create_live_call(db, course, day, rng=random):
|
||||||
|
student = pick_next_student(db, course, day, rng)
|
||||||
|
if student is None:
|
||||||
|
return None
|
||||||
|
call = Call(
|
||||||
|
course_id=course.id,
|
||||||
|
student_id=student.id,
|
||||||
|
session_date=day,
|
||||||
|
status=STATUS_PENDING,
|
||||||
|
)
|
||||||
|
db.add(call)
|
||||||
|
db.flush()
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
def generate_day_list(db, course, day, n=None, rng=random):
|
||||||
|
"""Create pending calls for a printable list. Cycle mode shuffles
|
||||||
|
the whole present roster; weighted mode draws n picks with
|
||||||
|
within-list downweighting, like the old manual script."""
|
||||||
|
present = students_present(db, course.id, day)
|
||||||
|
if not present:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ids = [s.id for s in present]
|
||||||
|
if course.selection_mode == SELECTION_CYCLE:
|
||||||
|
ordered = selection.generate_cycle_list(ids, rng)
|
||||||
|
else:
|
||||||
|
counts = answered_call_counts(db, course.id)
|
||||||
|
ordered = selection.generate_call_list(
|
||||||
|
ids, counts, n or len(ids), course.weight_factor, rng
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = [
|
||||||
|
Call(
|
||||||
|
course_id=course.id,
|
||||||
|
student_id=student_id,
|
||||||
|
session_date=day,
|
||||||
|
status=STATUS_PENDING,
|
||||||
|
)
|
||||||
|
for student_id in ordered
|
||||||
|
]
|
||||||
|
db.add_all(calls)
|
||||||
|
db.flush()
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def clear_pending(db, course_id, day):
|
||||||
|
removed = 0
|
||||||
|
for call in calls_for_day(db, course_id, day):
|
||||||
|
if call.status == STATUS_PENDING:
|
||||||
|
db.delete(call)
|
||||||
|
removed += 1
|
||||||
|
db.flush()
|
||||||
|
return removed
|
||||||
@@ -93,5 +93,5 @@ def fake_launch(user_id):
|
|||||||
session["is_instructor"] = is_instructor
|
session["is_instructor"] = is_instructor
|
||||||
|
|
||||||
if is_instructor:
|
if is_instructor:
|
||||||
return redirect(url_for("views.instructor_home"))
|
return redirect(url_for("instructor.home"))
|
||||||
return redirect(url_for("views.student_home"))
|
return redirect(url_for("views.student_home"))
|
||||||
|
|||||||
235
coldcall_lti/instructor.py
Normal file
235
coldcall_lti/instructor.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
abort,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
|
||||||
|
from . import calls
|
||||||
|
from .db import get_db
|
||||||
|
from .models import (
|
||||||
|
ASSESSMENTS,
|
||||||
|
CALL_STATUSES,
|
||||||
|
SELECTION_CYCLE,
|
||||||
|
SELECTION_WEIGHTED,
|
||||||
|
STATUS_ANSWERED,
|
||||||
|
STATUS_MISSING,
|
||||||
|
STATUS_SKIPPED,
|
||||||
|
Call,
|
||||||
|
Enrollment,
|
||||||
|
OptOut,
|
||||||
|
Student,
|
||||||
|
students_present,
|
||||||
|
)
|
||||||
|
from .views import current_course, require_instructor
|
||||||
|
|
||||||
|
bp = Blueprint("instructor", __name__, url_prefix="/instructor")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value):
|
||||||
|
if not value:
|
||||||
|
return datetime.date.today()
|
||||||
|
try:
|
||||||
|
return datetime.date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
abort(400, "Bad date; expected YYYY-MM-DD.")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_course_call(db, call_id):
|
||||||
|
call = db.get(Call, call_id)
|
||||||
|
if call is None or call.course_id != current_course().id:
|
||||||
|
abort(404)
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
def _day_context(db, course, day):
|
||||||
|
roster_count = db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Enrollment)
|
||||||
|
.where(
|
||||||
|
Enrollment.course_id == course.id,
|
||||||
|
Enrollment.role == "student",
|
||||||
|
Enrollment.active.is_(True),
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
present = students_present(db, course.id, day)
|
||||||
|
return {
|
||||||
|
"course": course,
|
||||||
|
"day": day,
|
||||||
|
"roster_count": roster_count,
|
||||||
|
"present_count": len(present),
|
||||||
|
"optout_count": roster_count - len(present),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/")
|
||||||
|
@require_instructor
|
||||||
|
def home():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
today = datetime.date.today()
|
||||||
|
|
||||||
|
recent_days = db.execute(
|
||||||
|
select(
|
||||||
|
Call.session_date,
|
||||||
|
func.count(),
|
||||||
|
func.sum(case((Call.status == STATUS_ANSWERED, 1), else_=0)),
|
||||||
|
)
|
||||||
|
.where(Call.course_id == course.id)
|
||||||
|
.group_by(Call.session_date)
|
||||||
|
.order_by(Call.session_date.desc())
|
||||||
|
.limit(10)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
context = _day_context(db, course, today)
|
||||||
|
context.update(recent_days=recent_days, today=today)
|
||||||
|
return render_template("instructor_home.html", **context)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/live")
|
||||||
|
@require_instructor
|
||||||
|
def live():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
day = _parse_date(request.args.get("date"))
|
||||||
|
|
||||||
|
call = calls.unresolved_call(db, course.id, day)
|
||||||
|
day_calls = [
|
||||||
|
c for c in calls.calls_for_day(db, course.id, day)
|
||||||
|
if c.status != calls.STATUS_PENDING
|
||||||
|
]
|
||||||
|
context = _day_context(db, course, day)
|
||||||
|
context.update(call=call, day_calls=day_calls, assessments=ASSESSMENTS)
|
||||||
|
return render_template("live.html", **context)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/live/next")
|
||||||
|
@require_instructor
|
||||||
|
def live_next():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
day = _parse_date(request.form.get("date"))
|
||||||
|
|
||||||
|
if calls.unresolved_call(db, course.id, day) is None:
|
||||||
|
calls.create_live_call(db, course, day)
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for(".live", date=day.isoformat()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/call/<int:call_id>/outcome")
|
||||||
|
@require_instructor
|
||||||
|
def call_outcome(call_id):
|
||||||
|
db = get_db()
|
||||||
|
call = _get_course_call(db, call_id)
|
||||||
|
|
||||||
|
action = request.form.get("action", "")
|
||||||
|
if action in ASSESSMENTS:
|
||||||
|
call.status = STATUS_ANSWERED
|
||||||
|
call.assessment = action
|
||||||
|
elif action == "skip":
|
||||||
|
call.status = STATUS_SKIPPED
|
||||||
|
call.assessment = None
|
||||||
|
elif action == "missing":
|
||||||
|
call.status = STATUS_MISSING
|
||||||
|
call.assessment = None
|
||||||
|
else:
|
||||||
|
abort(400, "Unknown outcome.")
|
||||||
|
db.commit()
|
||||||
|
return redirect(
|
||||||
|
url_for(".live", date=call.session_date.isoformat())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/day/<date_str>/generate")
|
||||||
|
@require_instructor
|
||||||
|
def generate(date_str):
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
day = _parse_date(date_str)
|
||||||
|
|
||||||
|
n = request.form.get("n", type=int)
|
||||||
|
calls.clear_pending(db, course.id, day)
|
||||||
|
calls.generate_day_list(db, course, day, n=n)
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for(".day_print", date_str=day.isoformat()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/day/<date_str>/print")
|
||||||
|
@require_instructor
|
||||||
|
def day_print(date_str):
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
day = _parse_date(date_str)
|
||||||
|
day_calls = calls.calls_for_day(db, course.id, day)
|
||||||
|
return render_template(
|
||||||
|
"print_list.html", course=course, day=day, day_calls=day_calls
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/day/<date_str>")
|
||||||
|
@require_instructor
|
||||||
|
def day_edit(date_str):
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
day = _parse_date(date_str)
|
||||||
|
context = _day_context(db, course, day)
|
||||||
|
context.update(
|
||||||
|
day_calls=calls.calls_for_day(db, course.id, day),
|
||||||
|
assessments=ASSESSMENTS,
|
||||||
|
statuses=CALL_STATUSES,
|
||||||
|
)
|
||||||
|
return render_template("day_edit.html", **context)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/day/<date_str>")
|
||||||
|
@require_instructor
|
||||||
|
def day_save(date_str):
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
day = _parse_date(date_str)
|
||||||
|
|
||||||
|
for call in calls.calls_for_day(db, course.id, day):
|
||||||
|
if request.form.get(f"delete-{call.id}"):
|
||||||
|
db.delete(call)
|
||||||
|
continue
|
||||||
|
status = request.form.get(f"status-{call.id}")
|
||||||
|
if status in CALL_STATUSES:
|
||||||
|
call.status = status
|
||||||
|
assessment = request.form.get(f"assessment-{call.id}", "")
|
||||||
|
call.assessment = assessment if assessment in ASSESSMENTS else None
|
||||||
|
note = request.form.get(f"note-{call.id}", "").strip()
|
||||||
|
call.note = note or None
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for(".day_edit", date_str=day.isoformat()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/settings")
|
||||||
|
@require_instructor
|
||||||
|
def settings():
|
||||||
|
return render_template(
|
||||||
|
"settings.html",
|
||||||
|
course=current_course(),
|
||||||
|
modes=(SELECTION_WEIGHTED, SELECTION_CYCLE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/settings")
|
||||||
|
@require_instructor
|
||||||
|
def settings_save():
|
||||||
|
db = get_db()
|
||||||
|
course = current_course()
|
||||||
|
|
||||||
|
mode = request.form.get("selection_mode")
|
||||||
|
if mode in (SELECTION_WEIGHTED, SELECTION_CYCLE):
|
||||||
|
course.selection_mode = mode
|
||||||
|
weight = request.form.get("weight_factor", type=float)
|
||||||
|
if weight and weight >= 1.0:
|
||||||
|
course.weight_factor = weight
|
||||||
|
course.show_assessments = bool(request.form.get("show_assessments"))
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for(".settings"))
|
||||||
@@ -62,7 +62,7 @@ def launch():
|
|||||||
session["is_instructor"] = info.is_instructor
|
session["is_instructor"] = info.is_instructor
|
||||||
|
|
||||||
if info.is_instructor:
|
if info.is_instructor:
|
||||||
return redirect(url_for("views.instructor_home"))
|
return redirect(url_for("instructor.home"))
|
||||||
return redirect(url_for("views.student_home"))
|
return redirect(url_for("views.student_home"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
table { border-collapse: collapse; }
|
table { border-collapse: collapse; }
|
||||||
th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; }
|
th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; }
|
||||||
.muted { color: #666; }
|
.muted { color: #666; }
|
||||||
|
.callcard { margin: 1.5rem 0; }
|
||||||
|
.callname { font-size: 2.25rem; font-weight: bold; margin: 0.5rem 0 0; }
|
||||||
|
button { padding: 0.4rem 0.8rem; margin: 0.15rem; }
|
||||||
|
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 .notes { min-width: 18rem; }
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none; }
|
||||||
|
body { margin: 0.5rem; max-width: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
44
coldcall_lti/templates/day_edit.html
Normal file
44
coldcall_lti/templates/day_edit.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Calls {{ day.isoformat() }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p><a href="{{ url_for('instructor.home') }}">← back</a> ·
|
||||||
|
<a href="{{ url_for('instructor.day_print', date_str=day.isoformat()) }}">printable list</a></p>
|
||||||
|
<h1>Calls for {{ day.isoformat() }}</h1>
|
||||||
|
|
||||||
|
{% if day_calls %}
|
||||||
|
<form method="post" action="{{ url_for('instructor.day_save', date_str=day.isoformat()) }}">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>#</th><th>Student</th><th>Status</th><th>Assessment</th>
|
||||||
|
<th>Note</th><th>Delete</th>
|
||||||
|
</tr>
|
||||||
|
{% for c in day_calls %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>{{ c.student.name }}</td>
|
||||||
|
<td>
|
||||||
|
<select name="status-{{ c.id }}">
|
||||||
|
{% for s in statuses %}
|
||||||
|
<option value="{{ s }}" {{ "selected" if c.status == s }}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="assessment-{{ c.id }}">
|
||||||
|
<option value=""></option>
|
||||||
|
{% for a in assessments %}
|
||||||
|
<option value="{{ a }}" {{ "selected" if c.assessment == a }}>{{ a }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" name="note-{{ c.id }}" value="{{ c.note or '' }}"></td>
|
||||||
|
<td><input type="checkbox" name="delete-{{ c.id }}"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<p><button type="submit">Save all</button></p>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>No calls recorded for this day.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -2,14 +2,42 @@
|
|||||||
{% 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>{{ roster|length }} active students on the roster.</p>
|
<p>
|
||||||
|
{{ roster_count }} active students on the roster;
|
||||||
|
{{ present_count }} available today ({{ optout_count }} opted out).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<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.settings') }}">Settings</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Printable list</h2>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('instructor.generate', date_str=today.isoformat()) }}">
|
||||||
|
<label>Number of calls (weighted mode only; cycle mode uses the whole
|
||||||
|
roster): <input type="number" name="n" min="1" value="{{ present_count }}"></label>
|
||||||
|
<button type="submit">Generate list for {{ today.isoformat() }}</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted">Generating replaces any unresolved calls already
|
||||||
|
pending for the day; recorded outcomes are kept.</p>
|
||||||
|
|
||||||
|
{% if recent_days %}
|
||||||
|
<h2>Recent days</h2>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>Name</th><th>Email</th></tr>
|
<tr><th>Date</th><th>Calls</th><th>Answered</th><th></th></tr>
|
||||||
{% for student in roster %}
|
{% for day, total, answered in recent_days %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ student.name }}</td>
|
<td>{{ day.isoformat() }}</td>
|
||||||
<td class="muted">{{ student.email or "" }}</td>
|
<td>{{ total }}</td>
|
||||||
|
<td>{{ answered or 0 }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('instructor.day_edit', date_str=day.isoformat()) }}">edit</a> ·
|
||||||
|
<a href="{{ url_for('instructor.day_print', date_str=day.isoformat()) }}">print</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</table>
|
</table>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
51
coldcall_lti/templates/live.html
Normal file
51
coldcall_lti/templates/live.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Live — {{ course.title or "Cold Call" }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p><a href="{{ url_for('instructor.home') }}">← back</a></p>
|
||||||
|
<h1>Live cold call — {{ day.isoformat() }}</h1>
|
||||||
|
<p class="muted">
|
||||||
|
{{ present_count }} of {{ roster_count }} students available
|
||||||
|
({{ optout_count }} opted out).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if call %}
|
||||||
|
<div class="callcard">
|
||||||
|
{% if call.student.avatar_url %}
|
||||||
|
<img src="{{ call.student.avatar_url }}" alt="" width="120">
|
||||||
|
{% endif %}
|
||||||
|
<p class="callname">{{ call.student.name }}</p>
|
||||||
|
{% if call.student.sortable_name %}
|
||||||
|
<p class="muted">{{ call.student.sortable_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('instructor.call_outcome', call_id=call.id) }}">
|
||||||
|
{% for a in assessments %}
|
||||||
|
<button type="submit" name="action" value="{{ a }}">{{ a }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" name="action" value="missing">Missing</button>
|
||||||
|
<button type="submit" name="action" value="skip">Skip</button>
|
||||||
|
</form>
|
||||||
|
{% elif present_count > 0 %}
|
||||||
|
<form method="post" action="{{ url_for('instructor.live_next') }}">
|
||||||
|
<input type="hidden" name="date" value="{{ day.isoformat() }}">
|
||||||
|
<button type="submit" class="big">Call next student</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>No students available to call.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if day_calls %}
|
||||||
|
<h2>Calls so far today</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>#</th><th>Student</th><th>Outcome</th></tr>
|
||||||
|
{% for c in day_calls %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>{{ c.student.name }}</td>
|
||||||
|
<td>{{ c.assessment or c.status }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
33
coldcall_lti/templates/print_list.html
Normal file
33
coldcall_lti/templates/print_list.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Call list {{ day.isoformat() }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p class="no-print">
|
||||||
|
<a href="{{ url_for('instructor.home') }}">← back</a> ·
|
||||||
|
<a href="{{ url_for('instructor.day_edit', date_str=day.isoformat()) }}">edit outcomes</a>
|
||||||
|
</p>
|
||||||
|
<h1>{{ course.title or course.lti_context_id }} — {{ day.isoformat() }}</h1>
|
||||||
|
{% if day_calls %}
|
||||||
|
<table class="printlist">
|
||||||
|
<tr><th>#</th><th></th><th>Student</th><th class="notes">Notes</th></tr>
|
||||||
|
{% for c in day_calls %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ loop.index }}</td>
|
||||||
|
<td>
|
||||||
|
{% if c.student.avatar_url %}
|
||||||
|
<img src="{{ c.student.avatar_url }}" alt="" width="48">
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ c.student.name }}
|
||||||
|
{% if c.student.sortable_name and c.student.sortable_name != c.student.name %}
|
||||||
|
<span class="muted">({{ c.student.sortable_name }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="notes"></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No calls generated for this day yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
34
coldcall_lti/templates/settings.html
Normal file
34
coldcall_lti/templates/settings.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings — {{ course.title or "Cold Call" }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p><a href="{{ url_for('instructor.home') }}">← back</a></p>
|
||||||
|
<h1>Course settings</h1>
|
||||||
|
<form method="post" action="{{ url_for('instructor.settings_save') }}">
|
||||||
|
<p>
|
||||||
|
<label>Selection mode:
|
||||||
|
<select name="selection_mode">
|
||||||
|
{% for m in modes %}
|
||||||
|
<option value="{{ m }}" {{ "selected" if course.selection_mode == m }}>{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="muted">weighted: students already called are less
|
||||||
|
likely to be called again; cycle: everyone once per class in
|
||||||
|
random order.</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label>Weight factor:
|
||||||
|
<input type="number" name="weight_factor" step="0.1" min="1"
|
||||||
|
value="{{ course.weight_factor }}"></label>
|
||||||
|
<span class="muted">Each answered call divides a student's
|
||||||
|
selection weight by this. 1 means uniform selection; 2 is the
|
||||||
|
long-standing default.</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><input type="checkbox" name="show_assessments"
|
||||||
|
{{ "checked" if course.show_assessments }}>
|
||||||
|
Students can see their own assessments</label>
|
||||||
|
</p>
|
||||||
|
<p><button type="submit">Save</button></p>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import functools
|
import functools
|
||||||
|
|
||||||
from flask import Blueprint, abort, render_template, session
|
from flask import Blueprint, abort, render_template, session
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .models import Course, Enrollment, Student
|
from .models import Course, Student
|
||||||
|
|
||||||
bp = Blueprint("views", __name__)
|
bp = Blueprint("views", __name__)
|
||||||
|
|
||||||
@@ -47,26 +46,6 @@ def index():
|
|||||||
return render_template("index.html", course=None, user=None)
|
return render_template("index.html", course=None, user=None)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/instructor")
|
|
||||||
@require_instructor
|
|
||||||
def instructor_home():
|
|
||||||
db = get_db()
|
|
||||||
course = current_course()
|
|
||||||
roster = 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)
|
|
||||||
).scalars().all()
|
|
||||||
return render_template(
|
|
||||||
"instructor_home.html", course=course, roster=roster
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/me")
|
@bp.get("/me")
|
||||||
@require_launch
|
@require_launch
|
||||||
def student_home():
|
def student_home():
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import pytest
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from coldcall_lti import create_app
|
||||||
|
from coldcall_lti.config import Config
|
||||||
from coldcall_lti.db import Base
|
from coldcall_lti.db import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -13,3 +15,20 @@ def db_session():
|
|||||||
yield session
|
yield session
|
||||||
session.close()
|
session.close()
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def make_app(tmp_path, dev_mode=True):
|
||||||
|
class TestConfig(Config):
|
||||||
|
TESTING = True
|
||||||
|
DATABASE_URL = f"sqlite:///{tmp_path}/test.sqlite3"
|
||||||
|
DEV_MODE = dev_mode
|
||||||
|
SECRET_KEY = "test"
|
||||||
|
|
||||||
|
app = create_app(TestConfig)
|
||||||
|
Base.metadata.create_all(app.extensions["db_engine"])
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dev_client(tmp_path):
|
||||||
|
return make_app(tmp_path, dev_mode=True).test_client()
|
||||||
|
|||||||
@@ -1,25 +1,4 @@
|
|||||||
import pytest
|
from conftest import make_app
|
||||||
|
|
||||||
from coldcall_lti import create_app
|
|
||||||
from coldcall_lti.config import Config
|
|
||||||
from coldcall_lti.db import Base
|
|
||||||
|
|
||||||
|
|
||||||
def make_app(tmp_path, dev_mode):
|
|
||||||
class TestConfig(Config):
|
|
||||||
TESTING = True
|
|
||||||
DATABASE_URL = f"sqlite:///{tmp_path}/test.sqlite3"
|
|
||||||
DEV_MODE = dev_mode
|
|
||||||
SECRET_KEY = "test"
|
|
||||||
|
|
||||||
app = create_app(TestConfig)
|
|
||||||
Base.metadata.create_all(app.extensions["db_engine"])
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def dev_client(tmp_path):
|
|
||||||
return make_app(tmp_path, dev_mode=True).test_client()
|
|
||||||
|
|
||||||
|
|
||||||
def test_healthz(tmp_path):
|
def test_healthz(tmp_path):
|
||||||
@@ -33,7 +12,7 @@ def test_dev_routes_absent_outside_dev_mode(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_views_require_launch(dev_client):
|
def test_views_require_launch(dev_client):
|
||||||
assert dev_client.get("/instructor").status_code == 403
|
assert dev_client.get("/instructor/").status_code == 403
|
||||||
assert dev_client.get("/me").status_code == 403
|
assert dev_client.get("/me").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +24,6 @@ def test_dev_instructor_launch_shows_roster(dev_client):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
page = resp.get_data(as_text=True)
|
page = resp.get_data(as_text=True)
|
||||||
assert "8 active students" in page
|
assert "8 active students" in page
|
||||||
assert "Ada Lovelace" in page
|
|
||||||
|
|
||||||
|
|
||||||
def test_dev_student_launch(dev_client):
|
def test_dev_student_launch(dev_client):
|
||||||
@@ -54,5 +32,6 @@ def test_dev_student_launch(dev_client):
|
|||||||
)
|
)
|
||||||
page = resp.get_data(as_text=True)
|
page = resp.get_data(as_text=True)
|
||||||
assert "Ada Lovelace" in page
|
assert "Ada Lovelace" in page
|
||||||
# A student cannot reach the instructor view.
|
# A student cannot reach the instructor views.
|
||||||
assert dev_client.get("/instructor").status_code == 403
|
assert dev_client.get("/instructor/").status_code == 403
|
||||||
|
assert dev_client.get("/instructor/live").status_code == 403
|
||||||
|
|||||||
131
tests/test_calls.py
Normal file
131
tests/test_calls.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import datetime
|
||||||
|
import random
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from coldcall_lti import calls, models
|
||||||
|
|
||||||
|
DAY = datetime.date(2026, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def make_course(db, n=4, mode=models.SELECTION_WEIGHTED):
|
||||||
|
course = models.Course(lti_context_id="ctx-1", selection_mode=mode)
|
||||||
|
db.add(course)
|
||||||
|
students = []
|
||||||
|
for i in range(n):
|
||||||
|
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 test_pick_next_honors_optouts(db_session):
|
||||||
|
course, students = make_course(db_session, n=2)
|
||||||
|
db_session.add(
|
||||||
|
models.OptOut(
|
||||||
|
course_id=course.id, student_id=students[0].id, date=DAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
rng = random.Random(1)
|
||||||
|
for _ in range(20):
|
||||||
|
assert calls.pick_next_student(db_session, course, DAY, rng).id == students[1].id
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_next_weighted_downweights(db_session):
|
||||||
|
course, students = make_course(db_session)
|
||||||
|
heavy = students[0]
|
||||||
|
for _ in range(4):
|
||||||
|
db_session.add(
|
||||||
|
models.Call(
|
||||||
|
course_id=course.id,
|
||||||
|
student_id=heavy.id,
|
||||||
|
session_date=DAY - datetime.timedelta(days=7),
|
||||||
|
status=models.STATUS_ANSWERED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
rng = random.Random(42)
|
||||||
|
picks = Counter(
|
||||||
|
calls.pick_next_student(db_session, course, DAY, rng).id
|
||||||
|
for _ in range(400)
|
||||||
|
)
|
||||||
|
assert picks[heavy.id] < min(picks[s.id] for s in students[1:]) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_mode_calls_everyone_before_repeating(db_session):
|
||||||
|
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
|
||||||
|
rng = random.Random(7)
|
||||||
|
called = []
|
||||||
|
for _ in range(len(students)):
|
||||||
|
call = calls.create_live_call(db_session, course, DAY, rng)
|
||||||
|
call.status = models.STATUS_ANSWERED
|
||||||
|
db_session.flush()
|
||||||
|
called.append(call.student_id)
|
||||||
|
assert sorted(called) == sorted(s.id for s in students)
|
||||||
|
|
||||||
|
# The next draw starts a fresh pass rather than failing.
|
||||||
|
assert calls.create_live_call(db_session, course, DAY, rng) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_mode_recalls_skipped_students(db_session):
|
||||||
|
course, students = make_course(db_session, n=2, mode=models.SELECTION_CYCLE)
|
||||||
|
rng = random.Random(3)
|
||||||
|
call = calls.create_live_call(db_session, course, DAY, rng)
|
||||||
|
call.status = models.STATUS_SKIPPED
|
||||||
|
db_session.flush()
|
||||||
|
# A skipped call doesn't count as having been called this pass.
|
||||||
|
pool_ids = {
|
||||||
|
calls.pick_next_student(db_session, course, DAY, rng).id
|
||||||
|
for _ in range(20)
|
||||||
|
}
|
||||||
|
assert call.student_id in pool_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_day_list_cycle_covers_roster(db_session):
|
||||||
|
course, students = make_course(db_session, mode=models.SELECTION_CYCLE)
|
||||||
|
generated = calls.generate_day_list(db_session, course, DAY, rng=random.Random(1))
|
||||||
|
assert sorted(c.student_id for c in generated) == sorted(s.id for s in students)
|
||||||
|
assert all(c.status == models.STATUS_PENDING for c in generated)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_day_list_weighted_length_and_optouts(db_session):
|
||||||
|
course, students = make_course(db_session)
|
||||||
|
db_session.add(
|
||||||
|
models.OptOut(
|
||||||
|
course_id=course.id, student_id=students[0].id, date=DAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.flush()
|
||||||
|
generated = calls.generate_day_list(
|
||||||
|
db_session, course, DAY, n=10, rng=random.Random(1)
|
||||||
|
)
|
||||||
|
assert len(generated) == 10
|
||||||
|
assert students[0].id not in {c.student_id for c in generated}
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_pending_keeps_resolved(db_session):
|
||||||
|
course, students = make_course(db_session)
|
||||||
|
generated = calls.generate_day_list(
|
||||||
|
db_session, course, DAY, n=5, rng=random.Random(1)
|
||||||
|
)
|
||||||
|
generated[0].status = models.STATUS_ANSWERED
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
removed = calls.clear_pending(db_session, course.id, DAY)
|
||||||
|
assert removed == 4
|
||||||
|
remaining = calls.calls_for_day(db_session, course.id, DAY)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].status == models.STATUS_ANSWERED
|
||||||
|
|
||||||
|
|
||||||
|
def test_unresolved_call_is_oldest_pending(db_session):
|
||||||
|
course, students = make_course(db_session)
|
||||||
|
generated = calls.generate_day_list(
|
||||||
|
db_session, course, DAY, n=3, rng=random.Random(1)
|
||||||
|
)
|
||||||
|
assert calls.unresolved_call(db_session, course.id, DAY).id == generated[0].id
|
||||||
121
tests/test_instructor_ui.py
Normal file
121
tests/test_instructor_ui.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
TODAY = datetime.date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def instructor(dev_client):
|
||||||
|
dev_client.post("/dev/launch/dev-instructor")
|
||||||
|
return dev_client
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_page(instructor):
|
||||||
|
page = instructor.get("/instructor/").get_data(as_text=True)
|
||||||
|
assert "8 active students" in page
|
||||||
|
assert "Live cold call" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_flow_records_outcome(instructor):
|
||||||
|
resp = instructor.post(
|
||||||
|
"/instructor/live/next", data={"date": TODAY}, follow_redirects=True
|
||||||
|
)
|
||||||
|
page = resp.get_data(as_text=True)
|
||||||
|
match = re.search(r'/instructor/call/(\d+)/outcome', page)
|
||||||
|
assert match, "live page should show outcome buttons for the call"
|
||||||
|
assert "GOOD" in page
|
||||||
|
|
||||||
|
call_id = match.group(1)
|
||||||
|
resp = instructor.post(
|
||||||
|
f"/instructor/call/{call_id}/outcome",
|
||||||
|
data={"action": "GOOD"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
page = resp.get_data(as_text=True)
|
||||||
|
assert "Calls so far today" in page
|
||||||
|
assert "GOOD" in page
|
||||||
|
# The call is resolved, so the next-student button is back.
|
||||||
|
assert "Call next student" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_requires_resolution_before_next(instructor):
|
||||||
|
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||||
|
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||||
|
first_call = re.search(r'/instructor/call/(\d+)/outcome', page).group(1)
|
||||||
|
|
||||||
|
# Pressing next again does not create a second pending call.
|
||||||
|
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||||
|
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||||
|
assert re.findall(r'/instructor/call/(\d+)/outcome', page) == [first_call]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_and_print(instructor):
|
||||||
|
resp = instructor.post(
|
||||||
|
f"/instructor/day/{TODAY}/generate",
|
||||||
|
data={"n": "12"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
page = resp.get_data(as_text=True)
|
||||||
|
assert page.count("<tr>") == 13 # header + 12 rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_replaces_pending(instructor):
|
||||||
|
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "12"})
|
||||||
|
resp = instructor.post(
|
||||||
|
f"/instructor/day/{TODAY}/generate",
|
||||||
|
data={"n": "5"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
assert resp.get_data(as_text=True).count("<tr>") == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_edit_saves_outcomes(instructor):
|
||||||
|
instructor.post(f"/instructor/day/{TODAY}/generate", data={"n": "2"})
|
||||||
|
page = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
|
||||||
|
ids = re.findall(r'name="status-(\d+)"', page)
|
||||||
|
assert len(ids) == 2
|
||||||
|
|
||||||
|
resp = instructor.post(
|
||||||
|
f"/instructor/day/{TODAY}",
|
||||||
|
data={
|
||||||
|
f"status-{ids[0]}": "answered",
|
||||||
|
f"assessment-{ids[0]}": "POOR",
|
||||||
|
f"note-{ids[0]}": "rough day",
|
||||||
|
f"status-{ids[1]}": "pending",
|
||||||
|
f"delete-{ids[1]}": "on",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
page = resp.get_data(as_text=True)
|
||||||
|
assert 'value="rough day"' in page
|
||||||
|
assert len(re.findall(r'name="status-(\d+)"', page)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_outcome_rejects_bad_action_and_foreign_call(instructor):
|
||||||
|
instructor.post("/instructor/live/next", data={"date": TODAY})
|
||||||
|
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
|
||||||
|
call_id = re.search(r'/instructor/call/(\d+)/outcome', page).group(1)
|
||||||
|
|
||||||
|
resp = instructor.post(
|
||||||
|
f"/instructor/call/{call_id}/outcome", data={"action": "nonsense"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
resp = instructor.post(
|
||||||
|
"/instructor/call/99999/outcome", data={"action": "GOOD"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_roundtrip(instructor):
|
||||||
|
resp = instructor.post(
|
||||||
|
"/instructor/settings",
|
||||||
|
data={"selection_mode": "cycle", "weight_factor": "3.0"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
page = resp.get_data(as_text=True)
|
||||||
|
assert '<option value="cycle" selected>' in page
|
||||||
|
assert 'value="3.0"' in page
|
||||||
|
# Checkbox left unchecked turns assessment visibility off.
|
||||||
|
assert "checked" not in page
|
||||||
Reference in New Issue
Block a user