Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions components/rsptx/auth/grader_permissions.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions components/rsptx/db/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
120 changes: 120 additions & 0 deletions components/rsptx/db/crud/grading_permissions.py
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions components/rsptx/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -----------
#
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 1 addition & 4 deletions projects/assignment_server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
2 changes: 1 addition & 1 deletion projects/book_server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading