1
0

Phase 6: participation grading, gradebook passback, opt-out integrity

Ports the timing-neutral foregone-participation grading scheme from
participation_grades.R: answer quality (per-course level points) minus
a deduction for participation foregone through unavailability,
estimated by Monte Carlo simulation of the actual weighted draw and
averaged over when absences fall, plus a small form-filing incentive
for drawn-while-absent-without-opt-out days. Reason-blind and
luck-protected; zero-answer students floor to 0. Parameters
(allowance in SD units, passing line, form penalty, simulation
size/seed) are course settings. Verified against the R engine's
rendered 2026q2 reports via the new import-legacy command: quality and
availability match exactly, finals within Monte Carlo noise; dropped
students import as inactive enrollments and are excluded identically.

Grades are computed on demand into stored GradeRun snapshots and
reviewed on a grades page with CSV export and per-student reports
(also served to students via a publish toggle). Display scales map
points to UW 4.0, a threshold table (one-click import of the Canvas
course grading scheme), or raw points. Gradebook passback via AGS
sits behind a settings toggle with a review-then-push flow.

Opt-out withdrawals are now soft-deletes with a withdrawn_at audit
trail, and close when class begins (class days gained optional start
times), so availability records cannot be rewritten after the fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 18:38:22 -07:00
parent 7ec1be5dc6
commit 46f7f55633
27 changed files with 1869 additions and 98 deletions

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###