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:
21
README.md
21
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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() }}
|
||||||
|
|||||||
34
migrations/versions/e7a15f49bc01_assessment_entered_at.py
Normal file
34
migrations/versions/e7a15f49bc01_assessment_entered_at.py
Normal 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 ###
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user