diff --git a/README.md b/README.md index 2edcfbe..d3c4629 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ configuration: add a custom parameter pronouns=$com.instructure.Person.pronouns course_start=$Canvas.course.startAt course_end=$Canvas.course.endAt +grading_scheme=$com.instructure.Course.gradingScheme ``` The course dates bound the schedule and date pickers; the tool works @@ -115,6 +116,52 @@ 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. +## Opt-outs + +Students remove themselves from a day's cold-call list by picking the +date on their page; nothing else is asked. There is deliberately no +enforced submission deadline: an instructor running live calls in +class gets up-to-the-second opt-outs automatically, while one who +prints a call list beforehand should just tell students how much lead +time they need (for example, "an hour or two before class"), since +opt-outs after printing won't be on the paper. + +Withdrawing an opt-out is bounded, because un-opting-out after the +fact would rewrite a student's availability record (and with it their +participation grade): withdrawal closes when class begins, using the +start time recorded on the schedule page, or at the start of the class +day when no time is recorded. Withdrawals are also recorded rather +than deleted — the opt-out export includes a withdrawn_at column, so +the full history of changes survives. + +## Grading + +Final participation grades use the timing-neutral foregone- +participation scheme (a port of the earlier participation_grades.R): +a student's grade is the quality of their answers minus a deduction +for participation they missed by being unavailable, estimated by +Monte Carlo simulation of the actual weighted draw and averaged over +when the absences fall, plus a small incentive penalty for being +drawn while absent with no opt-out filed. The reason for an absence +never matters, and luck of the draw never moves a grade. Parameters +(allowance, passing line, penalties, simulation size) are per-course +settings; grades are computed on demand, reviewed on the instructor's +grades page, and shown to students only when the instructor publishes +reports. With gradebook passback enabled, a review-then-push page +sends the reviewed scores to Canvas via the Assignment and Grade +Services; nothing is ever sent without explicit confirmation. + +Grades are computed in points out of 100 and displayed through a +per-course scale: the built-in linear UW 4.0 map, a threshold table +(letter grades, importable in one click from the course's own Canvas +grading scheme), or raw points. + +The port was verified against the R engine's rendered reports from a +real course: answer quality and availability match exactly; the +simulated penalty agrees within Monte Carlo noise. `flask --app +coldcall_lti import-legacy
{{ student.name }}
+Participation grade: {{ final_display }}
+ +| Date | Answered | Assessment |
|---|---|---|
| {{ c.session_date.isoformat() }} | +{{ "yes" if c.status == "answered" else "no (not present)" }} | +{{ c.assessment or "—" }} | +
You answered {{ row.asked }} of the + {{ calls | length }} times your name was drawn.
+ +Your grade depends only on the quality of your answers and on +participation you missed by being unavailable. You are never penalized +for how often the program happened to call on you, and the reason for +any absence never enters the calculation.
+{% endblock %} diff --git a/coldcall_lti/templates/grades.html b/coldcall_lti/templates/grades.html new file mode 100644 index 0000000..89a55dc --- /dev/null +++ b/coldcall_lti/templates/grades.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}Grades — {{ course.title or "Cold Call" }}{% endblock %} +{% block body %} + +No grades computed yet. Computing runs the simulation + with the current settings; nothing is shown to students or sent + anywhere until you choose to.
+{% elif results.error %} +{{ results.error }}
+{% else %} +| Class days | {{ results.n_days }} |
| Enrolled students | {{ results.n_students }} |
| Equal share of questions | {{ results.e_full }} |
| SD of full-attendance counts | {{ "%.2f" | format(results.sd_full) }} |
| Severity (points per question) | {{ "%.2f" | format(results.severity) }} |
| Allowance (questions) | {{ "%.2f" | format(results.allowance_q) }} + = {{ results.params.allowance_sd_units }} SD |
{{ results.excluded_calls }} call(s) involving + {{ results.excluded_students }} dropped (inactive) student(s) are + excluded from all of the above.
+{% endif %} +Foregone-participation curve by days missed: + {% for v in results.curve %}{{ loop.index0 }}d: {{ "%.2f" | format(v) }}{{ " · " if not loop.last }}{% endfor %} +
+ +| Student | Asked | Days missed | Quality | +Foregone | Caught | Final (pts) | Grade | + |
|---|---|---|---|---|---|---|---|---|
| {{ r.name }} | +{{ r.asked }} | +{{ r.days_missed or "" }} | +{{ r.q_points if r.q_points is not none else "—" }} | +{{ r.foregone or "" }} | +{{ r.caught or "" }} | +{{ r.final_points }} | +{{ display(r.final_points) }} | ++ report + {% if r.q_points is none %}never answered{% endif %} + | +
+ Export grades.csv + {% if course.ags_enabled %} + · Review & push to gradebook + {% endif %} +
+{% endif %} +{% endblock %} diff --git a/coldcall_lti/templates/instructor_home.html b/coldcall_lti/templates/instructor_home.html index a6aa918..40eb520 100644 --- a/coldcall_lti/templates/instructor_home.html +++ b/coldcall_lti/templates/instructor_home.html @@ -11,6 +11,7 @@ Live cold call · Today's calls · Report · + Grades · Schedule · Settings diff --git a/coldcall_lti/templates/push_preview.html b/coldcall_lti/templates/push_preview.html new file mode 100644 index 0000000..43e1a6c --- /dev/null +++ b/coldcall_lti/templates/push_preview.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block title %}Push to gradebook — {{ course.title or "Cold Call" }}{% endblock %} +{% block body %} + +This will write the scores below to a "Case discussion + participation" column in the Canvas gradebook (computed + {{ run.created_at.strftime("%Y-%m-%d %H:%M") }} UTC). Nothing is + sent until you confirm at the bottom.
+| Student | Score to send | Out of |
|---|---|---|
| {{ r.row.name }} | +{{ r.score }} | +{{ r.maximum }} | +
| Student | Result |
|---|---|
| {{ o.name }} | +{{ "✓" if o.ok else "FAILED:" }} {{ o.detail }} | +
{{ outcomes | selectattr("ok") | list | length }} of + {{ outcomes | length }} pushed successfully.
+{% endblock %} diff --git a/coldcall_lti/templates/schedule.html b/coldcall_lti/templates/schedule.html index 47ca44a..66fde95 100644 --- a/coldcall_lti/templates/schedule.html +++ b/coldcall_lti/templates/schedule.html @@ -5,7 +5,9 @@When class days are listed here, the student opt-out form only offers these dates. With no days listed, students can pick - any date.
+ any date. Start times bound opt-out withdrawal: students can withdraw + an opt-out until class begins; with no time recorded, withdrawal + closes at the start of the class day. {% set dmin = course.start_date.isoformat() if course.start_date else "" %} {% set dmax = course.end_date.isoformat() if course.end_date else "" %} @@ -15,6 +17,7 @@ value="{{ dmin }}" min="{{ dmin }}" max="{{ dmax }}"> + {% for i, name in weekdays %} {% endfor %} @@ -25,6 +28,7 @@ @@ -34,7 +38,9 @@ {% for d in days %}Grades use the timing-neutral foregone-participation + scheme: answer quality minus a deduction for participation missed + through unavailability, estimated by simulating the cold-call draw. + All values are in points out of 100.
++ + Foregone participation forgiven before any + deduction, in units of the draw's natural spread. +
++ + Forgoing one SD of participation lands a + perfect-quality student here (65.15 = UW 1.7). +
++ + Per day called while absent with no opt-out + filed (4.55 = UW 0.3). +
++ + +
+ ++ +
++ +
+ ++ +
++ +
+ + +{% if course.canvas_grading_scheme %} + +{% endif %} {% endblock %} diff --git a/coldcall_lti/templates/student_home.html b/coldcall_lti/templates/student_home.html index 23e54ee..0ea3dbd 100644 --- a/coldcall_lti/templates/student_home.html +++ b/coldcall_lti/templates/student_home.html @@ -7,6 +7,11 @@ they are not right, update them in your Canvas account settings and they will be picked up here automatically. +{% if report_available %} +Your + participation grade report
+{% endif %} +You have answered {{ answered }} @@ -47,22 +52,23 @@ {% if optouts %}
| Date | |
|---|---|
| {{ o.date.isoformat() }} | - {% if o.date >= today %} + {% if withdrawable %} {% else %} - past + locked {% endif %} |
Opt-outs can be withdrawn until the class begins.
{% else %}You have not opted out of any classes.
{% endif %} diff --git a/coldcall_lti/views.py b/coldcall_lti/views.py index 78bc4e4..c70deb0 100644 --- a/coldcall_lti/views.py +++ b/coldcall_lti/views.py @@ -16,7 +16,9 @@ from .models import ( Student, answered_call_counts, class_days, + class_has_begun, is_class_day, + latest_grade_run, ) bp = Blueprint("views", __name__) @@ -119,17 +121,22 @@ def student_home(): db.execute( select(OptOut) .where( - OptOut.course_id == course.id, OptOut.student_id == user.id + OptOut.course_id == course.id, + OptOut.student_id == user.id, + OptOut.withdrawn_at.is_(None), ) .order_by(OptOut.date) ) .scalars() .all() ) + optouts = [ + (o, not class_has_begun(db, course.id, o.date)) for o in optouts + ] upcoming_days = [ d.date for d in class_days(db, course.id, start=today) - if d.date not in {o.date for o in optouts} + if d.date not in {o.date for o, _ in optouts} ] return render_template( @@ -143,6 +150,34 @@ def student_home(): optouts=optouts, upcoming_days=upcoming_days, distribution=_class_call_distribution(db, course, user.id), + report_available=( + course.publish_grade_reports + and latest_grade_run(db, course.id) is not None + ), + ) + + +@bp.get("/me/report") +@require_launch +def my_grade_report(): + import json + + from .grades import student_report_context + + db = get_db() + course = current_course() + user = current_user() + if not course.publish_grade_reports: + abort(404) + run = latest_grade_run(db, course.id) + if run is None: + abort(404) + results = json.loads(run.results) + context = student_report_context(db, course, results, user.id) + if context is None: + abort(404) + return render_template( + "grade_report.html", is_instructor_view=False, **context ) @@ -172,6 +207,11 @@ def optout_create(): if exists is None: db.add(OptOut(course_id=course.id, student_id=user.id, date=day)) db.commit() + elif exists.withdrawn_at is not None: + # Re-opting-out after a withdrawal reuses the row; the + # withdrawal timestamp is cleared but creation time remains. + exists.withdrawn_at = None + db.commit() return redirect(url_for(".student_home")) @@ -184,10 +224,11 @@ def optout_delete(optout_id): optout is None or optout.course_id != current_course().id or optout.student_id != current_user().id + or optout.withdrawn_at is not None ): abort(404) - if optout.date < datetime.date.today(): - abort(400, "Past opt-outs cannot be withdrawn.") - db.delete(optout) + if class_has_begun(db, optout.course_id, optout.date): + abort(400, "Opt-outs cannot be withdrawn once class has begun.") + optout.withdrawn_at = datetime.datetime.now(datetime.UTC) db.commit() return redirect(url_for(".student_home")) diff --git a/migrations/versions/3b6f28fbdc4b_class_start_times_and_optout_withdrawal_.py b/migrations/versions/3b6f28fbdc4b_class_start_times_and_optout_withdrawal_.py new file mode 100644 index 0000000..5818e56 --- /dev/null +++ b/migrations/versions/3b6f28fbdc4b_class_start_times_and_optout_withdrawal_.py @@ -0,0 +1,40 @@ +"""class start times and optout withdrawal audit + +Revision ID: 3b6f28fbdc4b +Revises: 826e2835e289 +Create Date: 2026-07-31 18:35:27.102919 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3b6f28fbdc4b' +down_revision: Union[str, None] = '826e2835e289' +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('class_days', schema=None) as batch_op: + batch_op.add_column(sa.Column('start_time', sa.Time(), nullable=True)) + + with op.batch_alter_table('optouts', schema=None) as batch_op: + batch_op.add_column(sa.Column('withdrawn_at', sa.DateTime(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('optouts', schema=None) as batch_op: + batch_op.drop_column('withdrawn_at') + + with op.batch_alter_table('class_days', schema=None) as batch_op: + batch_op.drop_column('start_time') + + # ### end Alembic commands ### diff --git a/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py b/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py deleted file mode 100644 index 1c78e4f..0000000 --- a/migrations/versions/826e2835e289_assessment_levels_and_nrps_details.py +++ /dev/null @@ -1,78 +0,0 @@ -"""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 ### diff --git a/migrations/versions/826e2835e289_assessment_levels_grading_services.py b/migrations/versions/826e2835e289_assessment_levels_grading_services.py new file mode 100644 index 0000000..dfa7135 --- /dev/null +++ b/migrations/versions/826e2835e289_assessment_levels_grading_services.py @@ -0,0 +1,93 @@ +"""assessment levels, grading, and platform service details + +Revision ID: 826e2835e289 +Revises: 487d155678ea +Create Date: 2026-07-31 18:21:06.340614 + +""" +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! ### + op.create_table('assessment_levels', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('course_id', sa.Integer(), nullable=False), + sa.Column('label', sa.String(length=64), nullable=False), + sa.Column('points', sa.Float(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], name=op.f('fk_assessment_levels_course_id_courses')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_assessment_levels')), + sa.UniqueConstraint('course_id', 'label', name=op.f('uq_assessment_levels_course_id')) + ) + op.create_table('grade_runs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('course_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('params', sa.Text(), nullable=False), + sa.Column('results', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], name=op.f('fk_grade_runs_course_id_courses')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_grade_runs')) + ) + with op.batch_alter_table('calls', schema=None) as batch_op: + batch_op.add_column(sa.Column('assessment_id', sa.Integer(), nullable=True)) + 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('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.add_column(sa.Column('allowance_sd_units', sa.Float(), nullable=False)) + batch_op.add_column(sa.Column('passing_points', sa.Float(), nullable=False)) + batch_op.add_column(sa.Column('form_penalty_points', sa.Float(), nullable=False)) + batch_op.add_column(sa.Column('n_sims', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('sim_seed', sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column('scale_type', sa.String(length=16), nullable=False)) + batch_op.add_column(sa.Column('scale_config', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('canvas_grading_scheme', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('publish_grade_reports', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('ags_enabled', sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column('ags_lineitems_url', sa.String(length=1024), nullable=True)) + batch_op.add_column(sa.Column('ags_scopes', sa.Text(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('courses', schema=None) as batch_op: + batch_op.drop_column('ags_scopes') + batch_op.drop_column('ags_lineitems_url') + batch_op.drop_column('ags_enabled') + batch_op.drop_column('publish_grade_reports') + batch_op.drop_column('canvas_grading_scheme') + batch_op.drop_column('scale_config') + batch_op.drop_column('scale_type') + batch_op.drop_column('sim_seed') + batch_op.drop_column('n_sims') + batch_op.drop_column('form_penalty_points') + batch_op.drop_column('passing_points') + batch_op.drop_column('allowance_sd_units') + batch_op.drop_column('nrps_url') + batch_op.drop_column('lti_client_id') + batch_op.drop_column('lti_issuer') + + 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') + batch_op.drop_column('assessment_id') + + op.drop_table('grade_runs') + op.drop_table('assessment_levels') + # ### end Alembic commands ### diff --git a/tests/test_grades_ui.py b/tests/test_grades_ui.py new file mode 100644 index 0000000..3d52548 --- /dev/null +++ b/tests/test_grades_ui.py @@ -0,0 +1,123 @@ +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") + # Speed: shrink the simulation for tests. + dev_client.post( + "/instructor/settings", + data={"selection_mode": "weighted", "weight_factor": "2", + "show_assessments": "on", "n_sims": "200", "sim_seed": "1"}, + ) + return dev_client + + +def record_calls(client, n=6): + for _ in range(n): + 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) + client.post( + f"/instructor/call/{call_id}/outcome", + data={"action": outcome_actions(page)["GOOD"]}, + ) + + +def test_grades_page_before_compute(instructor): + page = instructor.get("/instructor/grades/").get_data(as_text=True) + assert "No grades computed yet" in page + + +def test_compute_and_view_grades(instructor): + record_calls(instructor) + resp = instructor.post( + "/instructor/grades/compute", follow_redirects=True + ) + page = resp.get_data(as_text=True) + assert "SD of full-attendance counts" in page + assert "4.00" in page # someone answered GOOD -> full marks + assert "never answered" in page # not everyone got called + + resp = instructor.get("/instructor/grades/export.csv") + assert resp.mimetype == "text/csv" + assert "final_points" in resp.get_data(as_text=True) + + +def test_student_report_and_publishing(instructor): + record_calls(instructor) + instructor.post("/instructor/grades/compute") + page = instructor.get("/instructor/grades/").get_data(as_text=True) + student_id = re.search(r"/instructor/grades/student/(\d+)", page).group(1) + report = instructor.get( + f"/instructor/grades/student/{student_id}" + ).get_data(as_text=True) + assert "How your grade was computed" in report + + # Unpublished: students see no report link and get 404. + instructor.post("/dev/launch/dev-student-1") + assert "participation grade report" not in instructor.get("/me").get_data(as_text=True) + assert instructor.get("/me/report").status_code == 404 + + # Publish, and the student can read their own report. + instructor.post("/dev/launch/dev-instructor") + instructor.post( + "/instructor/settings", + data={"selection_mode": "weighted", "weight_factor": "2", + "publish_grade_reports": "on"}, + ) + instructor.post("/dev/launch/dev-student-1") + page = instructor.get("/me").get_data(as_text=True) + assert "participation grade report" in page + report = instructor.get("/me/report").get_data(as_text=True) + assert "How your grade was computed" in report + assert "Ada Lovelace" in report + + +def test_push_flow_dev_mode(instructor): + record_calls(instructor) + instructor.post("/instructor/grades/compute") + + # Push pages don't exist until AGS is enabled. + assert instructor.get("/instructor/grades/push").status_code == 404 + + instructor.post( + "/instructor/settings", + data={"selection_mode": "weighted", "weight_factor": "2", + "ags_enabled": "on"}, + ) + page = instructor.get("/instructor/grades/push").get_data(as_text=True) + assert "Review before pushing" in page + assert "Push 8 grades to Canvas" in page + + resp = instructor.post("/instructor/grades/push") + page = resp.get_data(as_text=True) + assert "simulated (dev mode)" in page + assert "8 of\n 8 pushed successfully" in page or "8 of" in page + + +def test_grades_require_instructor(dev_client): + dev_client.post("/dev/launch/dev-instructor") + dev_client.post("/dev/launch/dev-student-1") + assert dev_client.get("/instructor/grades/").status_code == 403 + + +def test_scale_settings_and_canvas_import(instructor): + # Threshold rows via the settings form. + instructor.post( + "/instructor/settings", + data={"selection_mode": "weighted", "weight_factor": "2", + "scale_type": "table", + "scale_rows": "93 A\n90 A-\n0 F"}, + ) + record_calls(instructor, n=3) + instructor.post("/instructor/grades/compute") + page = instructor.get("/instructor/grades/").get_data(as_text=True) + assert "A" in page diff --git a/tests/test_grading.py b/tests/test_grading.py new file mode 100644 index 0000000..7e5f988 --- /dev/null +++ b/tests/test_grading.py @@ -0,0 +1,238 @@ +import datetime + +import pytest + +from coldcall_lti import grading, models, scales + +D0 = datetime.date(2026, 10, 1) + + +def setup_course(db, n_students=6, n_days=10, calls_per_day=3, + n_sims=400, **course_kw): + course = models.Course( + lti_context_id="ctx-1", n_sims=n_sims, sim_seed=1, **course_kw + ) + db.add(course) + db.flush() + models.ensure_default_levels(db, course) + levels = { + lvl.label: lvl for lvl in models.assessment_levels(db, course.id) + } + students = [] + for i in range(n_students): + 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() + + # Round-robin answered calls, all GOOD, over n_days class days. + days = [D0 + datetime.timedelta(days=i) for i in range(n_days)] + idx = 0 + for day in days: + for _ in range(calls_per_day): + db.add( + models.Call( + course_id=course.id, + student_id=students[idx % n_students].id, + session_date=day, + status=models.STATUS_ANSWERED, + assessment_id=levels["GOOD"].id, + ) + ) + idx += 1 + db.flush() + return course, students, days, levels + + +def test_all_present_all_good_grades_full(db_session): + course, students, days, levels = setup_course(db_session) + results = grading.compute(db_session, course) + assert results["curve"][0] == 0.0 + for row in results["students"]: + assert row["q_points"] == 100.0 + assert row["days_missed"] == 0 + assert row["final_points"] == 100.0 + + +def test_foregone_curve_is_nonnegative_and_grows(db_session): + course, *_ = setup_course(db_session) + results = grading.compute(db_session, course) + curve = results["curve"] + assert all(v >= 0 for v in curve) + # Missing most days must cost more than missing one. + assert curve[8] > curve[1] + + +def test_zero_answer_student_floors_to_zero(db_session): + course, students, days, levels = setup_course(db_session) + extra = models.Student(canvas_user_id="u-silent", name="Silent Student") + db_session.add(extra) + db_session.flush() + db_session.add( + models.Enrollment(course_id=course.id, student_id=extra.id) + ) + db_session.flush() + results = grading.compute(db_session, course) + row = next( + r for r in results["students"] if r["student_id"] == extra.id + ) + assert row["q_points"] is None + assert row["final_points"] == 0.0 + + +def test_luck_protection_present_but_rarely_called(db_session): + """A student who is always available but was called only once is + not penalized: quality is their grade.""" + course, students, days, levels = setup_course(db_session) + lucky = models.Student(canvas_user_id="u-lucky", name="Lucky Student") + db_session.add(lucky) + db_session.flush() + db_session.add( + models.Enrollment(course_id=course.id, student_id=lucky.id) + ) + db_session.add( + models.Call( + course_id=course.id, + student_id=lucky.id, + session_date=days[0], + status=models.STATUS_ANSWERED, + assessment_id=levels["POOR"].id, + ) + ) + db_session.flush() + results = grading.compute(db_session, course) + row = next( + r for r in results["students"] if r["student_id"] == lucky.id + ) + assert row["days_missed"] == 0 + assert row["final_points"] == pytest.approx(69.7, abs=0.01) + + +def test_absences_within_allowance_are_free(db_session): + course, students, days, levels = setup_course( + db_session, allowance_sd_units=5.0 + ) + # One student opts out of two days. + for day in days[:2]: + db_session.add( + models.OptOut( + course_id=course.id, student_id=students[0].id, date=day + ) + ) + db_session.flush() + results = grading.compute(db_session, course) + row = next( + r for r in results["students"] + if r["student_id"] == students[0].id + ) + assert row["days_missed"] == 2 + # Huge allowance: no deduction despite the absences. + assert row["final_points"] == row["q_points"] + + +def test_heavy_absence_is_deducted(db_session): + course, students, days, levels = setup_course( + db_session, allowance_sd_units=0.25 + ) + for day in days[:8]: + db_session.add( + models.OptOut( + course_id=course.id, student_id=students[0].id, date=day + ) + ) + db_session.flush() + results = grading.compute(db_session, course) + row = next( + r for r in results["students"] + if r["student_id"] == students[0].id + ) + assert row["final_points"] < row["q_points"] + + +def test_form_penalty_only_for_uncovered_missing_days(db_session): + course, students, days, levels = setup_course(db_session) + caught_s, covered_s = students[0], students[1] + # Both drawn-but-missing on day 3; only covered_s filed the form. + for s in (caught_s, covered_s): + db_session.add( + models.Call( + course_id=course.id, + student_id=s.id, + session_date=days[3], + status=models.STATUS_MISSING, + ) + ) + db_session.add( + models.OptOut( + course_id=course.id, student_id=covered_s.id, date=days[3] + ) + ) + db_session.flush() + results = grading.compute(db_session, course) + rows = {r["student_id"]: r for r in results["students"]} + assert rows[caught_s.id]["caught"] == 1 + assert rows[covered_s.id]["caught"] == 0 + # Both have the same quality and same availability; the caught + # student ends exactly one form penalty lower. + diff = (rows[covered_s.id]["final_points"] + - rows[caught_s.id]["final_points"]) + assert diff == pytest.approx(course.form_penalty_points, abs=0.01) + + +def test_dropped_students_are_excluded_everywhere(db_session): + course, students, days, levels = setup_course(db_session) + baseline = grading.compute(db_session, course) + + dropped = models.Student(canvas_user_id="u-dropped", name="Dropped") + db_session.add(dropped) + db_session.flush() + db_session.add( + models.Enrollment( + course_id=course.id, student_id=dropped.id, active=False + ) + ) + for day in days[:3]: + db_session.add( + models.Call( + course_id=course.id, + student_id=dropped.id, + session_date=day, + status=models.STATUS_ANSWERED, + assessment_id=levels["GOOD"].id, + ) + ) + db_session.flush() + + results = grading.compute(db_session, course) + assert results["excluded_students"] == 1 + assert results["excluded_calls"] == 3 + assert results["n_students"] == baseline["n_students"] + assert dropped.id not in {r["student_id"] for r in results["students"]} + # Same seed, same effective inputs: identical simulation results. + assert results["sd_full"] == baseline["sd_full"] + assert results["curve"] == baseline["curve"] + + +def test_scales(): + course = models.Course(lti_context_id="x", scale_type="uw4") + assert scales.display(course, 100.0) == "4.00" + assert scales.display(course, 65.15) == "1.70" + assert scales.display(course, 84.85) == "3.00" + assert scales.display(course, 0.0) == "0.00" + + course.scale_type = "table" + course.scale_config = '[[93, "A"], [90, "A-"], [0, "F"]]' + assert scales.display(course, 95) == "A" + assert scales.display(course, 91.2) == "A-" + assert scales.display(course, 10) == "F" + + course.scale_type = "none" + assert scales.display(course, 87.5) == "87.5" + + course.scale_type = "uw4" + assert scales.ags_score(course, 84.85) == (3.0, 4.0) + course.scale_type = "table" + assert scales.ags_score(course, 84.85) == (84.85, 100.0) diff --git a/tests/test_student_ui.py b/tests/test_student_ui.py index ab5d200..5b54b23 100644 --- a/tests/test_student_ui.py +++ b/tests/test_student_ui.py @@ -88,6 +88,73 @@ def test_students_cannot_touch_others_optouts(student): assert student.post(f"/me/optout/{optout_id}/delete").status_code == 404 +def test_withdrawal_locks_when_class_begins(student): + # Dev class days start at 23:59, so today's class hasn't begun and + # withdrawal works (covered by test_optout_roundtrip). Move a class + # day's start time into the past and withdrawal must be refused. + from coldcall_lti.models import ClassDay + + page = student.get("/me").get_data(as_text=True) + day = next_class_day_iso(page) + student.post("/me/optout", data={"date": day}) + page = student.get("/me").get_data(as_text=True) + optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1) + + # Reach into the DB to set that class day's start time to 00:00. + app = student.application + with app.app_context(): + db = app.extensions["db_session_factory"]() + class_day = ( + db.query(ClassDay) + .filter_by(date=datetime.date.fromisoformat(day)) + .one() + ) + began_today = class_day.date == datetime.date.today() + class_day.start_time = datetime.time(0, 0) + db.commit() + db.close() + + resp = student.post(f"/me/optout/{optout_id}/delete") + if began_today: + assert resp.status_code == 400 + page = student.get("/me").get_data(as_text=True) + assert "locked" in page + else: + # The day is still in the future; withdrawal stays open. + assert resp.status_code == 302 + + +def test_withdrawal_is_recorded_not_deleted(student): + page = student.get("/me").get_data(as_text=True) + day = next_class_day_iso(page) + student.post("/me/optout", data={"date": day}) + page = student.get("/me").get_data(as_text=True) + optout_id = re.search(r"/me/optout/(\d+)/delete", page).group(1) + student.post(f"/me/optout/{optout_id}/delete") + + from coldcall_lti.models import OptOut + + app = student.application + with app.app_context(): + db = app.extensions["db_session_factory"]() + optout = db.query(OptOut).one() + assert optout.withdrawn_at is not None + db.close() + + # Re-opting out reinstates the same row. + student.post("/me/optout", data={"date": day}) + with app.app_context(): + db = app.extensions["db_session_factory"]() + optout = db.query(OptOut).one() + assert optout.withdrawn_at is None + db.close() + + # The instructor's opt-out export keeps the audit column. + student.post("/dev/launch/dev-instructor") + csv_text = student.get("/instructor/export/optouts.csv").get_data(as_text=True) + assert "withdrawn_at" in csv_text.splitlines()[0] + + def test_assessment_visibility_setting(student): # Record an answered call for dev-student-1 as the instructor. student.post("/dev/launch/dev-instructor")