diff --git a/components/rsptx/auth/grader_permissions.py b/components/rsptx/auth/grader_permissions.py new file mode 100644 index 000000000..ef5015d8c --- /dev/null +++ b/components/rsptx/auth/grader_permissions.py @@ -0,0 +1,67 @@ +"""Resolve grading capabilities without granting instructor access.""" + +from dataclasses import dataclass +from enum import StrEnum + +from rsptx.db.crud import ( + fetch_assignment_grading_policy, + fetch_instructor_courses, + is_course_grader, +) + + +class GraderRole(StrEnum): + NONE = "none" + INSTRUCTOR = "instructor" + DELEGATED_GRADER = "delegated_grader" + + +@dataclass(frozen=True) +class GraderCapabilities: + """Effective permissions for one user and assignment.""" + + role: GraderRole + can_grade: bool + can_view_student_identities: bool + assignment_blind_grading: bool + + +NO_GRADER_CAPABILITIES = GraderCapabilities( + role=GraderRole.NONE, + can_grade=False, + can_view_student_identities=False, + assignment_blind_grading=False, +) + + +async def resolve_grader_capabilities( + *, user_id: int, course_id: int, assignment_id: int +) -> GraderCapabilities: + """Resolve grading access without broadening existing instructor access. + + A full instructor always wins over delegated membership and retains student + identities. A delegated grader can grade only when the assignment belongs + to the requested course and has explicitly enabled blind grading. + """ + + policy = await fetch_assignment_grading_policy(assignment_id) + if policy is None or policy.course_id != course_id: + return NO_GRADER_CAPABILITIES + + if await fetch_instructor_courses(user_id, course_id): + return GraderCapabilities( + role=GraderRole.INSTRUCTOR, + can_grade=True, + can_view_student_identities=True, + assignment_blind_grading=policy.blind_grading, + ) + + if policy.blind_grading and await is_course_grader(course_id, user_id): + return GraderCapabilities( + role=GraderRole.DELEGATED_GRADER, + can_grade=True, + can_view_student_identities=False, + assignment_blind_grading=True, + ) + + return NO_GRADER_CAPABILITIES diff --git a/components/rsptx/db/crud/__init__.py b/components/rsptx/db/crud/__init__.py index 3c0a01610..56d26d8b4 100644 --- a/components/rsptx/db/crud/__init__.py +++ b/components/rsptx/db/crud/__init__.py @@ -103,6 +103,17 @@ get_course_origin, ) +from .grading_permissions import ( + AssignmentGradingPolicy, + fetch_assignment_grading_policy, + fetch_course_grader, + fetch_course_graders, + grant_course_grader, + is_course_grader, + revoke_course_grader, + set_assignment_blind_grading, +) + from .book import ( count_reading_activities, create_user_chapter_progress_entry, @@ -467,6 +478,18 @@ "get_course_origin", ] +# from .grading_permissions +__all__ += [ + "AssignmentGradingPolicy", + "fetch_assignment_grading_policy", + "fetch_course_grader", + "fetch_course_graders", + "grant_course_grader", + "is_course_grader", + "revoke_course_grader", + "set_assignment_blind_grading", +] + # from .group __all__ += [ "create_group", diff --git a/components/rsptx/db/crud/grading_permissions.py b/components/rsptx/db/crud/grading_permissions.py new file mode 100644 index 000000000..b701cb920 --- /dev/null +++ b/components/rsptx/db/crud/grading_permissions.py @@ -0,0 +1,120 @@ +"""Persistence helpers for delegated graders and blind-grading policy.""" + +from typing import NamedTuple, Optional + +from sqlalchemy import delete, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from ..async_session import async_session +from ..models import Assignment, CourseGrader, CourseGraderValidator + + +class AssignmentGradingPolicy(NamedTuple): + """The course boundary and effective blind-grading state for an assignment.""" + + course_id: int + blind_grading: bool + + +async def grant_course_grader(course_id: int, user_id: int) -> CourseGraderValidator: + """Grant restricted grader membership, returning the existing row if any.""" + + async with async_session.begin() as session: + await session.execute( + pg_insert(CourseGrader) + .values(course_id=course_id, user_id=user_id) + .on_conflict_do_nothing( + constraint="uq_course_grader_course_user", + ) + ) + result = await session.execute( + select(CourseGrader).where( + (CourseGrader.course_id == course_id) + & (CourseGrader.user_id == user_id) + ) + ) + membership = result.scalar_one() + return CourseGraderValidator.from_orm(membership) + + +async def revoke_course_grader(course_id: int, user_id: int) -> bool: + """Revoke restricted grader membership; return whether a row was removed.""" + + async with async_session.begin() as session: + result = await session.execute( + delete(CourseGrader).where( + (CourseGrader.course_id == course_id) + & (CourseGrader.user_id == user_id) + ) + ) + return bool(result.rowcount) + + +async def fetch_course_grader( + course_id: int, user_id: int +) -> Optional[CourseGraderValidator]: + """Return one restricted grader membership, scoped to its course.""" + + async with async_session() as session: + result = await session.execute( + select(CourseGrader).where( + (CourseGrader.course_id == course_id) + & (CourseGrader.user_id == user_id) + ) + ) + membership = result.scalar_one_or_none() + return ( + CourseGraderValidator.from_orm(membership) + if membership is not None + else None + ) + + +async def fetch_course_graders(course_id: int) -> list[CourseGraderValidator]: + """Return every restricted grader membership for a course.""" + + async with async_session() as session: + result = await session.execute( + select(CourseGrader) + .where(CourseGrader.course_id == course_id) + .order_by(CourseGrader.id) + ) + return [CourseGraderValidator.from_orm(row) for row in result.scalars()] + + +async def is_course_grader(course_id: int, user_id: int) -> bool: + """Return whether a user has restricted grader membership in a course.""" + + return await fetch_course_grader(course_id, user_id) is not None + + +async def fetch_assignment_grading_policy( + assignment_id: int, +) -> Optional[AssignmentGradingPolicy]: + """Return an assignment's course and effective blind-grading setting.""" + + async with async_session() as session: + result = await session.execute( + select(Assignment.course, Assignment.blind_grading).where( + Assignment.id == assignment_id + ) + ) + row = result.one_or_none() + if row is None: + return None + return AssignmentGradingPolicy( + course_id=row.course, + blind_grading=bool(row.blind_grading), + ) + + +async def set_assignment_blind_grading(assignment_id: int, enabled: bool) -> bool: + """Persist blind-grading policy; return whether the assignment existed.""" + + async with async_session.begin() as session: + result = await session.execute( + update(Assignment) + .where(Assignment.id == assignment_id) + .values(blind_grading=enabled) + ) + return bool(result.rowcount) diff --git a/components/rsptx/db/models.py b/components/rsptx/db/models.py index c8e0cf13c..877ddc7fe 100644 --- a/components/rsptx/db/models.py +++ b/components/rsptx/db/models.py @@ -565,6 +565,36 @@ class CourseInstructor(Base, IdMixin): CourseInstructorValidator: TypeAlias = sqlalchemy_to_pydantic(CourseInstructor) # type: ignore +class CourseGrader(Base, IdMixin): + """A user who may grade only through the restricted grader workflow. + + This is intentionally separate from ``CourseInstructor``. Adding a user to + this table must not grant access to the existing instructor endpoints, + roster, gradebook, or student reports. + """ + + __tablename__ = "course_grader" + __table_args__ = ( + UniqueConstraint("course_id", "user_id", name="uq_course_grader_course_user"), + ) + + course_id = Column( + Integer, + ForeignKey("courses.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id = Column( + Integer, + ForeignKey("auth_user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + +CourseGraderValidator: TypeAlias = sqlalchemy_to_pydantic(CourseGrader) # type: ignore + + # Enrollments # ----------- # @@ -648,6 +678,9 @@ class Assignment(Base, IdMixin): name = Column(String(512), nullable=False) points = Column(Integer, default=0) released = Column(Web2PyBoolean, nullable=False) + # Server-enforced blind grading is opt-in per assignment. NULL is treated as + # False so older rows and callers that omit the field remain identified. + blind_grading = Column(Web2PyBoolean) description = Column(Text) duedate = Column(DateTime, nullable=False) updated_date = Column(DateTime, nullable=True) diff --git a/migrations/versions/c2f8a1d4e7b9_add_blind_grading_foundation.py b/migrations/versions/c2f8a1d4e7b9_add_blind_grading_foundation.py new file mode 100644 index 000000000..0c766c1f5 --- /dev/null +++ b/migrations/versions/c2f8a1d4e7b9_add_blind_grading_foundation.py @@ -0,0 +1,63 @@ +"""add blind grading role and assignment policy + +Revision ID: c2f8a1d4e7b9 +Revises: a1c7e93d40b8 +Create Date: 2026-09-26 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "c2f8a1d4e7b9" +down_revision: Union[str, None] = "a1c7e93d40b8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "course_grader", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("course_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["course_id"], ["courses.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "course_id", "user_id", name="uq_course_grader_course_user" + ), + ) + op.create_index( + op.f("ix_course_grader_course_id"), + "course_grader", + ["course_id"], + unique=False, + ) + op.create_index( + op.f("ix_course_grader_user_id"), + "course_grader", + ["user_id"], + unique=False, + ) + + op.add_column( + "assignments", + sa.Column( + "blind_grading", + sa.CHAR(length=1), + nullable=True, + server_default=sa.text("'F'"), + ), + ) + op.execute("UPDATE assignments SET blind_grading = 'F' WHERE blind_grading IS NULL") + + +def downgrade() -> None: + op.drop_column("assignments", "blind_grading") + op.drop_index(op.f("ix_course_grader_user_id"), table_name="course_grader") + op.drop_index(op.f("ix_course_grader_course_id"), table_name="course_grader") + op.drop_table("course_grader") diff --git a/projects/assignment_server/pyproject.toml b/projects/assignment_server/pyproject.toml index f27229a2e..106751d6f 100644 --- a/projects/assignment_server/pyproject.toml +++ b/projects/assignment_server/pyproject.toml @@ -3,13 +3,10 @@ name = "assignment_server" version = "0.1.1" description = "" authors = [{ name = "Brad Miller", email = "bonelake@mac.com" }] -requires-python = ">=3.10,<4" +requires-python = ">=3.13,<4" license = "MIT" classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] diff --git a/projects/book_server/pyproject.toml b/projects/book_server/pyproject.toml index 483c6c8d4..96d755152 100644 --- a/projects/book_server/pyproject.toml +++ b/projects/book_server/pyproject.toml @@ -3,7 +3,7 @@ name = "book_server" version = "2.0.1" description = "" authors = [{ name = "Brad Miller", email = "bonelake@mac.com" }] -requires-python = ">=3.10,<4" +requires-python = ">=3.13,<4" dependencies = [ "aioredis>=2.0.0,<3", "aiohttp>=3.11.11,<4", diff --git a/test/components/rsptx/auth/test_grader_permissions.py b/test/components/rsptx/auth/test_grader_permissions.py new file mode 100644 index 000000000..30e1855b6 --- /dev/null +++ b/test/components/rsptx/auth/test_grader_permissions.py @@ -0,0 +1,226 @@ +"""Tests for the dormant delegated-grader permission foundation.""" + +import asyncio +import datetime + +import pytest + +from rsptx.auth.grader_permissions import ( + GraderRole, + resolve_grader_capabilities, +) +from rsptx.db.crud import ( + create_assignment, + create_instructor_course_entry, + fetch_assignment_grading_policy, + fetch_course, + fetch_course_grader, + fetch_course_graders, + grant_course_grader, + is_course_grader, + revoke_course_grader, + set_assignment_blind_grading, +) +from rsptx.db.crud.user import create_user +from rsptx.db.models import AssignmentValidator, AuthUserValidator + + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +async def _make_user(username: str): + return await create_user( + AuthUserValidator( + username=username, + first_name="Blind", + last_name="Grader", + password="xxx", + email=f"{username}@example.com", + course_name="overview", + course_id=1, + donated=True, + active=True, + accept_tcp=True, + created_on=datetime.datetime(2026, 1, 1), + modified_on=datetime.datetime(2026, 1, 1), + registration_key="", + registration_id="", + reset_password_key="", + ) + ) + + +async def _make_assignment(course_id: int, name: str): + return await create_assignment( + AssignmentValidator( + course=course_id, + name=name, + points=0, + released=False, + duedate=datetime.datetime(2099, 1, 1), + visible=True, + from_source=False, + is_peer=False, + current_index=0, + peer_async_visible=False, + ) + ) + + +@pytest.fixture(scope="session") +async def grader_permission_world(init_test_db): + course = await fetch_course("test_course_1") + other_course = await fetch_course("overview") + delegated = await _make_user("blind_grader_delegated") + scoped = await _make_user("blind_grader_scoped") + concurrent = await _make_user("blind_grader_concurrent") + revocable = await _make_user("blind_grader_revocable") + instructor = await _make_user("blind_grader_instructor") + unrelated = await _make_user("blind_grader_unrelated") + identified_assignment = await _make_assignment( + course.id, "Blind grading permissions identified" + ) + blind_assignment = await _make_assignment( + course.id, "Blind grading permissions anonymous" + ) + await set_assignment_blind_grading(blind_assignment.id, True) + await grant_course_grader(course.id, delegated.id) + await grant_course_grader(course.id, instructor.id) + await create_instructor_course_entry(instructor.id, course.id) + + return { + "course": course, + "other_course": other_course, + "delegated": delegated, + "scoped": scoped, + "concurrent": concurrent, + "revocable": revocable, + "instructor": instructor, + "unrelated": unrelated, + "identified_assignment": identified_assignment, + "blind_assignment": blind_assignment, + } + + +async def test_course_grader_grant_is_idempotent_and_course_scoped( + grader_permission_world, +): + world = grader_permission_world + first = await grant_course_grader(world["course"].id, world["scoped"].id) + second = await grant_course_grader(world["course"].id, world["scoped"].id) + + assert first.id == second.id + assert await is_course_grader(world["course"].id, world["scoped"].id) + assert not await is_course_grader(world["other_course"].id, world["scoped"].id) + memberships = await fetch_course_graders(world["course"].id) + assert sum(row.user_id == world["scoped"].id for row in memberships) == 1 + + +async def test_concurrent_course_grader_grants_return_the_same_membership( + grader_permission_world, +): + world = grader_permission_world + first, second = await asyncio.gather( + grant_course_grader(world["course"].id, world["concurrent"].id), + grant_course_grader(world["course"].id, world["concurrent"].id), + ) + + assert first.id == second.id + memberships = await fetch_course_graders(world["course"].id) + assert sum(row.user_id == world["concurrent"].id for row in memberships) == 1 + + +async def test_course_grader_can_be_revoked(grader_permission_world): + world = grader_permission_world + await grant_course_grader(world["course"].id, world["revocable"].id) + + assert await revoke_course_grader(world["course"].id, world["revocable"].id) + assert await fetch_course_grader(world["course"].id, world["revocable"].id) is None + assert not await revoke_course_grader(world["course"].id, world["revocable"].id) + + +async def test_assignment_blind_grading_defaults_off_and_can_be_changed( + grader_permission_world, +): + assignment = grader_permission_world["identified_assignment"] + policy = await fetch_assignment_grading_policy(assignment.id) + + assert policy is not None + assert policy.course_id == grader_permission_world["course"].id + assert policy.blind_grading is False + + assert await set_assignment_blind_grading(assignment.id, True) + assert (await fetch_assignment_grading_policy(assignment.id)).blind_grading is True + assert await set_assignment_blind_grading(assignment.id, False) + assert (await fetch_assignment_grading_policy(assignment.id)).blind_grading is False + assert not await set_assignment_blind_grading(999_999_999, True) + assert await fetch_assignment_grading_policy(999_999_999) is None + + +async def test_delegated_grader_is_fail_closed_for_identified_assignment( + grader_permission_world, +): + world = grader_permission_world + capabilities = await resolve_grader_capabilities( + user_id=world["delegated"].id, + course_id=world["course"].id, + assignment_id=world["identified_assignment"].id, + ) + + assert capabilities.role is GraderRole.NONE + assert capabilities.can_grade is False + assert capabilities.can_view_student_identities is False + assert capabilities.assignment_blind_grading is False + + +async def test_delegated_grader_can_grade_only_anonymous_assignment( + grader_permission_world, +): + world = grader_permission_world + capabilities = await resolve_grader_capabilities( + user_id=world["delegated"].id, + course_id=world["course"].id, + assignment_id=world["blind_assignment"].id, + ) + + assert capabilities.role is GraderRole.DELEGATED_GRADER + assert capabilities.can_grade is True + assert capabilities.can_view_student_identities is False + assert capabilities.assignment_blind_grading is True + + +async def test_full_instructor_takes_precedence_over_delegated_membership( + grader_permission_world, +): + world = grader_permission_world + capabilities = await resolve_grader_capabilities( + user_id=world["instructor"].id, + course_id=world["course"].id, + assignment_id=world["blind_assignment"].id, + ) + + assert capabilities.role is GraderRole.INSTRUCTOR + assert capabilities.can_grade is True + assert capabilities.can_view_student_identities is True + assert capabilities.assignment_blind_grading is True + + +async def test_unrelated_user_and_cross_course_request_have_no_capabilities( + grader_permission_world, +): + world = grader_permission_world + unrelated = await resolve_grader_capabilities( + user_id=world["unrelated"].id, + course_id=world["course"].id, + assignment_id=world["blind_assignment"].id, + ) + wrong_course = await resolve_grader_capabilities( + user_id=world["delegated"].id, + course_id=world["other_course"].id, + assignment_id=world["blind_assignment"].id, + ) + + assert unrelated.role is GraderRole.NONE + assert unrelated.can_grade is False + assert wrong_course.role is GraderRole.NONE + assert wrong_course.can_grade is False