From 4e29baddf89c7957f355eabfaae388af10162604 Mon Sep 17 00:00:00 2001 From: Benjamin Mako Hill Date: Mon, 3 Aug 2026 13:35:25 -0700 Subject: [PATCH] Setup banner, assessment timestamps, and installation docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 21 ++++- coldcall_lti/instructor.py | 17 ++++- coldcall_lti/models.py | 6 ++ coldcall_lti/templates/base.html | 1 + coldcall_lti/templates/instructor_home.html | 16 ++++ .../e7a15f49bc01_assessment_entered_at.py | 34 +++++++++ pyproject.toml | 6 +- tests/test_instructor_ui.py | 76 ++++++++++++++++++- 8 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 migrations/versions/e7a15f49bc01_assessment_entered_at.py diff --git a/README.md b/README.md index 0479b56..967aeb8 100644 --- a/README.md +++ b/README.md @@ -68,17 +68,30 @@ roster. ## Setup -Development uses a virtualenv that shares the system's Debian-packaged -libraries (Flask, SQLAlchemy, alembic, pytest) and adds the one -PyPI-only dependency, the maintained `pylti1p3next` fork of PyLTI1p3: +The app needs Python ≥ 3.11 with Flask, SQLAlchemy, alembic, and +numpy, plus one library that only exists on PyPI: `pylti1p3next`, the +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 .venv/bin/pip install pylti1p3next .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 diff --git a/coldcall_lti/instructor.py b/coldcall_lti/instructor.py index 2dd763d..6653d31 100644 --- a/coldcall_lti/instructor.py +++ b/coldcall_lti/instructor.py @@ -34,6 +34,7 @@ from .models import ( assessment_levels, class_days, students_present, + utcnow, ) from .roster import sync_roster from .views import current_course, require_instructor @@ -111,11 +112,13 @@ def home(): day_calls = calls.calls_for_day(db, course.id, day) context = _day_context(db, course, day) + all_class_days = class_days(db, course.id) context.update( recent_days=recent_days, today=today, + has_schedule=bool(all_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], has_pending=any( c.status == STATUS_PENDING for c in day_calls @@ -182,6 +185,7 @@ def call_outcome(call_id): call.assessment_id = None else: abort(400, "Unknown outcome.") + call.assessment_entered_at = utcnow() db.commit() return redirect( url_for(".live", date=call.session_date.isoformat()) @@ -279,6 +283,9 @@ def day_add_call(date_str): status=status, assessment_id=assessment if assessment in level_ids else None, note=(request.form.get("note", "").strip() or None), + assessment_entered_at=( + utcnow() if status != STATUS_PENDING else None + ), ) db.add(call) db.commit() @@ -297,11 +304,14 @@ def day_save(date_str): if request.form.get(f"delete-{call.id}"): db.delete(call) continue + before = (call.status, call.assessment_id) status = request.form.get(f"status-{call.id}") if status in CALL_STATUSES: call.status = status assessment = request.form.get(f"assessment-{call.id}", type=int) 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() call.note = note or None db.commit() @@ -399,12 +409,15 @@ def export_calls(): return _csv_response( "calls.csv", ["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, student.canvas_user_id, call.status, call.assessment or "", call.note or "", call.created_at.isoformat(), + call.assessment_entered_at.isoformat() + if call.assessment_entered_at else "", ] for call, student in rows ], diff --git a/coldcall_lti/models.py b/coldcall_lti/models.py index 2a5109d..4c536c4 100644 --- a/coldcall_lti/models.py +++ b/coldcall_lti/models.py @@ -231,6 +231,12 @@ class Call(Base): created_at: Mapped[datetime.datetime] = mapped_column( 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() level: Mapped["AssessmentLevel | None"] = relationship() diff --git a/coldcall_lti/templates/base.html b/coldcall_lti/templates/base.html index 5cdfbfe..511659f 100644 --- a/coldcall_lti/templates/base.html +++ b/coldcall_lti/templates/base.html @@ -10,6 +10,7 @@ table { border-collapse: collapse; } th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; } .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; } .callname { font-size: 2.25rem; font-weight: bold; margin: 0.5rem 0 0; } .pronouns { font-size: 1.25rem; font-weight: normal; color: #555; } diff --git a/coldcall_lti/templates/instructor_home.html b/coldcall_lti/templates/instructor_home.html index c62c478..5bf1163 100644 --- a/coldcall_lti/templates/instructor_home.html +++ b/coldcall_lti/templates/instructor_home.html @@ -2,6 +2,22 @@ {% block title %}{{ course.title or "Cold Call" }}{% endblock %} {% block body %}

{{ course.title or course.lti_context_id }}

+ +{% if not has_schedule %} + +{% endif %} +

{{ roster_count }} active students on the roster; {{ present_count }} available on {{ day.isoformat() }} diff --git a/migrations/versions/e7a15f49bc01_assessment_entered_at.py b/migrations/versions/e7a15f49bc01_assessment_entered_at.py new file mode 100644 index 0000000..2496519 --- /dev/null +++ b/migrations/versions/e7a15f49bc01_assessment_entered_at.py @@ -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 ### diff --git a/pyproject.toml b/pyproject.toml index d9850b2..f2a00f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,13 +10,15 @@ license = {text = "AGPL-3.0-or-later"} authors = [{name = "Benjamin Mako Hill", email = "mako@atdot.cc"}] requires-python = ">=3.11" # 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 -# but pylti1p3next; these floors record what the code was written against). +# numpy 2.2.4, pylti1p3next 2.0.2 (development uses Debian system +# packages for all but pylti1p3next; these floors record what the code +# was written against). dependencies = [ "flask>=3.1", "pylti1p3next>=2.0", "SQLAlchemy>=2.0", "alembic>=1.13", + "numpy>=1.26", ] [project.optional-dependencies] diff --git a/tests/test_instructor_ui.py b/tests/test_instructor_ui.py index e719c65..cb050da 100644 --- a/tests/test_instructor_ui.py +++ b/tests/test_instructor_ui.py @@ -5,7 +5,8 @@ import pytest from conftest import outcome_actions -TODAY = datetime.date.today().isoformat() +TODAY_DATE = datetime.date.today() +TODAY = TODAY_DATE.isoformat() @pytest.fixture @@ -95,6 +96,79 @@ def test_regenerate_replaces_pending_and_labels_change(instructor): 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', 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): tomorrow = ( datetime.date.today() + datetime.timedelta(days=1)