1
0

Setup banner, assessment timestamps, and installation docs

Instructor home shows a setup banner while a course has no class
schedule, since that is the one gap that degrades the student
experience (free-date opt-outs, no withdrawal locking); it points at
the prefilled schedule form and settings and disappears once any
class day exists.

Calls gain assessment_entered_at, stamped when an outcome is recorded
or actually changed (bulk saves that change nothing don't restamp),
null for pending calls and legacy imports, and included in the calls
export — distinguishing assessed-in-class from assessed-later.

The README setup section now gives both install routes: pure
pip/venv, and Debian system packages with the apt line. Fixing this
uncovered that numpy was missing from the project dependencies (the
grading engine needs it; the system-site-packages venv had masked
the omission).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 13:35:25 -07:00
parent 5fec2e3a3d
commit 4e29baddf8
8 changed files with 168 additions and 9 deletions

View File

@@ -68,17 +68,30 @@ roster.
## Setup ## Setup
Development uses a virtualenv that shares the system's Debian-packaged The app needs Python ≥ 3.11 with Flask, SQLAlchemy, alembic, and
libraries (Flask, SQLAlchemy, alembic, pytest) and adds the one numpy, plus one library that only exists on PyPI: `pylti1p3next`, the
PyPI-only dependency, the maintained `pylti1p3next` fork of PyLTI1p3: maintained fork of PyLTI1p3. There are two equivalent ways to install,
depending on whether you prefer distribution packages or pip.
**Pure pip/venv** (any distribution; everything from PyPI):
``` ```
python3 -m venv .venv
.venv/bin/pip install -e '.[test]'
```
**Debian/Ubuntu system packages** (only `pylti1p3next` comes from
PyPI; the venv shares the system's packages):
```
sudo apt install python3-venv python3-flask python3-sqlalchemy \
python3-alembic python3-numpy python3-pytest
python3 -m venv --system-site-packages .venv python3 -m venv --system-site-packages .venv
.venv/bin/pip install pylti1p3next .venv/bin/pip install pylti1p3next
.venv/bin/pip install -e . .venv/bin/pip install -e .
``` ```
Create the database and run the tests: Either way, then create the database and run the tests:
``` ```
.venv/bin/python -m alembic upgrade head .venv/bin/python -m alembic upgrade head

View File

@@ -34,6 +34,7 @@ from .models import (
assessment_levels, assessment_levels,
class_days, class_days,
students_present, students_present,
utcnow,
) )
from .roster import sync_roster from .roster import sync_roster
from .views import current_course, require_instructor from .views import current_course, require_instructor
@@ -111,11 +112,13 @@ def home():
day_calls = calls.calls_for_day(db, course.id, day) day_calls = calls.calls_for_day(db, course.id, day)
context = _day_context(db, course, day) context = _day_context(db, course, day)
all_class_days = class_days(db, course.id)
context.update( context.update(
recent_days=recent_days, recent_days=recent_days,
today=today, today=today,
has_schedule=bool(all_class_days),
upcoming_class_days=[ upcoming_class_days=[
d.date for d in class_days(db, course.id, start=today) d.date for d in all_class_days if d.date >= today
][:4], ][:4],
has_pending=any( has_pending=any(
c.status == STATUS_PENDING for c in day_calls c.status == STATUS_PENDING for c in day_calls
@@ -182,6 +185,7 @@ def call_outcome(call_id):
call.assessment_id = None call.assessment_id = None
else: else:
abort(400, "Unknown outcome.") abort(400, "Unknown outcome.")
call.assessment_entered_at = utcnow()
db.commit() db.commit()
return redirect( return redirect(
url_for(".live", date=call.session_date.isoformat()) url_for(".live", date=call.session_date.isoformat())
@@ -279,6 +283,9 @@ def day_add_call(date_str):
status=status, status=status,
assessment_id=assessment if assessment in level_ids else None, assessment_id=assessment if assessment in level_ids else None,
note=(request.form.get("note", "").strip() or None), note=(request.form.get("note", "").strip() or None),
assessment_entered_at=(
utcnow() if status != STATUS_PENDING else None
),
) )
db.add(call) db.add(call)
db.commit() db.commit()
@@ -297,11 +304,14 @@ def day_save(date_str):
if request.form.get(f"delete-{call.id}"): if request.form.get(f"delete-{call.id}"):
db.delete(call) db.delete(call)
continue continue
before = (call.status, call.assessment_id)
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}", type=int) assessment = request.form.get(f"assessment-{call.id}", type=int)
call.assessment_id = assessment if assessment in level_ids else None call.assessment_id = assessment if assessment in level_ids else None
if (call.status, call.assessment_id) != before:
call.assessment_entered_at = utcnow()
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()
@@ -399,12 +409,15 @@ def export_calls():
return _csv_response( return _csv_response(
"calls.csv", "calls.csv",
["date", "name", "email", "canvas_user_id", ["date", "name", "email", "canvas_user_id",
"status", "assessment", "note", "created_at"], "status", "assessment", "note", "created_at",
"assessment_entered_at"],
[ [
[ [
call.session_date.isoformat(), student.name, student.email, call.session_date.isoformat(), student.name, student.email,
student.canvas_user_id, call.status, call.assessment or "", student.canvas_user_id, call.status, call.assessment or "",
call.note or "", call.created_at.isoformat(), call.note or "", call.created_at.isoformat(),
call.assessment_entered_at.isoformat()
if call.assessment_entered_at else "",
] ]
for call, student in rows for call, student in rows
], ],

View File

@@ -231,6 +231,12 @@ class Call(Base):
created_at: Mapped[datetime.datetime] = mapped_column( created_at: Mapped[datetime.datetime] = mapped_column(
DateTime, default=utcnow DateTime, default=utcnow
) )
# When the outcome (status/assessment) was last recorded or
# changed. Distinguishes assessed-in-class from assessed-later;
# null for pending calls and for imported legacy data.
assessment_entered_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime
)
student: Mapped[Student] = relationship() student: Mapped[Student] = relationship()
level: Mapped["AssessmentLevel | None"] = relationship() level: Mapped["AssessmentLevel | None"] = relationship()

View File

@@ -10,6 +10,7 @@
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; }
.banner { background: #fff8e1; border: 1px solid #d9c26e; border-radius: 4px; padding: 0.25rem 1rem; margin: 1rem 0; }
.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; } .pronouns { font-size: 1.25rem; font-weight: normal; color: #555; }

View File

@@ -2,6 +2,22 @@
{% 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>
{% if not has_schedule %}
<div class="banner">
<p><strong>No class schedule yet.</strong> Until you add your meeting
days, students can report an absence for <em>any</em> date, and
opt-out withdrawals can't be locked to class start times.
<a href="{{ url_for('instructor.schedule') }}">Set up the
schedule</a> — pick your weekdays and the range is prefilled from
the Canvas course dates when available.</p>
<p class="muted">Also worth one look before your first class:
<a href="{{ url_for('instructor.settings') }}">course settings</a>
(selection mode, weight, the assessment scale). Grading parameters
can wait until the end of the term.</p>
</div>
{% endif %}
<p> <p>
{{ roster_count }} active students on the roster; {{ roster_count }} active students on the roster;
{{ present_count }} available on {{ day.isoformat() }} {{ present_count }} available on {{ day.isoformat() }}

View File

@@ -0,0 +1,34 @@
"""assessment entered at
Revision ID: e7a15f49bc01
Revises: 3b6f28fbdc4b
Create Date: 2026-08-03 13:31:01.094907
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7a15f49bc01'
down_revision: Union[str, None] = '3b6f28fbdc4b'
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('calls', schema=None) as batch_op:
batch_op.add_column(sa.Column('assessment_entered_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('calls', schema=None) as batch_op:
batch_op.drop_column('assessment_entered_at')
# ### end Alembic commands ###

View File

@@ -10,13 +10,15 @@ license = {text = "AGPL-3.0-or-later"}
authors = [{name = "Benjamin Mako Hill", email = "mako@atdot.cc"}] authors = [{name = "Benjamin Mako Hill", email = "mako@atdot.cc"}]
requires-python = ">=3.11" requires-python = ">=3.11"
# Known-good versions: flask 3.1.1, SQLAlchemy 2.0.40, alembic 1.13.2, # Known-good versions: flask 3.1.1, SQLAlchemy 2.0.40, alembic 1.13.2,
# pylti1p3next 2.0.2 (development uses Debian system packages for all # numpy 2.2.4, pylti1p3next 2.0.2 (development uses Debian system
# but pylti1p3next; these floors record what the code was written against). # packages for all but pylti1p3next; these floors record what the code
# was written against).
dependencies = [ dependencies = [
"flask>=3.1", "flask>=3.1",
"pylti1p3next>=2.0", "pylti1p3next>=2.0",
"SQLAlchemy>=2.0", "SQLAlchemy>=2.0",
"alembic>=1.13", "alembic>=1.13",
"numpy>=1.26",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -5,7 +5,8 @@ import pytest
from conftest import outcome_actions from conftest import outcome_actions
TODAY = datetime.date.today().isoformat() TODAY_DATE = datetime.date.today()
TODAY = TODAY_DATE.isoformat()
@pytest.fixture @pytest.fixture
@@ -95,6 +96,79 @@ def test_regenerate_replaces_pending_and_labels_change(instructor):
assert "already have recorded outcomes" in page assert "already have recorded outcomes" in page
def test_assessment_entered_at_stamping(instructor):
from coldcall_lti.models import Call
# Live flow: pending call has no stamp; recording an outcome sets it.
instructor.post("/instructor/live/next", data={"date": TODAY})
page = instructor.get(f"/instructor/live?date={TODAY}").get_data(as_text=True)
call_id = int(re.search(r"/instructor/call/(\d+)/outcome", page).group(1))
app = instructor.application
def entered_at(cid):
with app.app_context():
db = app.extensions["db_session_factory"]()
value = db.get(Call, cid).assessment_entered_at
db.close()
return value
assert entered_at(call_id) is None
instructor.post(
f"/instructor/call/{call_id}/outcome",
data={"action": outcome_actions(page)["GOOD"]},
)
first_stamp = entered_at(call_id)
assert first_stamp is not None
# A day-editor save that changes nothing does not restamp; a real
# change does.
edit = instructor.get(f"/instructor/day/{TODAY}").get_data(as_text=True)
good_id = re.search(
r'value="(\d+)" selected>GOOD</option>', edit
).group(1)
instructor.post(
f"/instructor/day/{TODAY}",
data={f"status-{call_id}": "answered",
f"assessment-{call_id}": good_id},
)
assert entered_at(call_id) == first_stamp
instructor.post(
f"/instructor/day/{TODAY}",
data={f"status-{call_id}": "missing"},
)
assert entered_at(call_id) != first_stamp
# The export carries the column.
csv_text = instructor.get("/instructor/export/calls.csv").get_data(as_text=True)
assert "assessment_entered_at" in csv_text.splitlines()[0]
def test_setup_banner_until_schedule_exists(instructor):
# The dev course seeds a schedule, so no banner by default.
page = instructor.get("/instructor/").get_data(as_text=True)
assert "No class schedule yet" not in page
# Strip the schedule; the banner appears.
from coldcall_lti.models import ClassDay
app = instructor.application
with app.app_context():
db = app.extensions["db_session_factory"]()
db.query(ClassDay).delete()
db.commit()
db.close()
page = instructor.get("/instructor/").get_data(as_text=True)
assert "No class schedule yet" in page
# Adding a single class day dismisses it.
future = (TODAY_DATE + datetime.timedelta(days=30)).isoformat()
instructor.post("/instructor/schedule/add", data={"date": future})
page = instructor.get("/instructor/").get_data(as_text=True)
assert "No class schedule yet" not in page
def test_home_working_date_override(instructor): def test_home_working_date_override(instructor):
tomorrow = ( tomorrow = (
datetime.date.today() + datetime.timedelta(days=1) datetime.date.today() + datetime.timedelta(days=1)