diff --git a/lms/migrations/versions/af090ca7e73f_add_assignment_checkpoint_table.py b/lms/migrations/versions/af090ca7e73f_add_assignment_checkpoint_table.py new file mode 100644 index 0000000000..3fea847575 --- /dev/null +++ b/lms/migrations/versions/af090ca7e73f_add_assignment_checkpoint_table.py @@ -0,0 +1,43 @@ +"""Add assignment_checkpoint table.""" + +import sqlalchemy as sa +from alembic import op + +revision = "af090ca7e73f" +down_revision = "b91594c0e379" + + +def upgrade() -> None: + op.create_table( + "assignment_checkpoint", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("assignment_id", sa.Integer(), nullable=False), + sa.Column("reveal_date", sa.DateTime(), nullable=True), + sa.Column( + "created", sa.DateTime(), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated", sa.DateTime(), server_default=sa.func.now(), nullable=False + ), + sa.ForeignKeyConstraint( + ["assignment_id"], + ["assignment.id"], + name=op.f("fk__assignment_checkpoint__assignment_id__assignment"), + ondelete="cascade", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk__assignment_checkpoint")), + ) + op.create_index( + op.f("ix__assignment_checkpoint_assignment_id"), + "assignment_checkpoint", + ["assignment_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix__assignment_checkpoint_assignment_id"), + table_name="assignment_checkpoint", + ) + op.drop_table("assignment_checkpoint") diff --git a/lms/models/__init__.py b/lms/models/__init__.py index 349a0c37fc..68b3f081f5 100644 --- a/lms/models/__init__.py +++ b/lms/models/__init__.py @@ -6,6 +6,7 @@ AutoGradingConfig, AutoGradingType, ) +from lms.models.assignment_checkpoint import AssignmentCheckpoint from lms.models.assignment_grouping import AssignmentGrouping from lms.models.assignment_membership import ( AssignmentMembership, diff --git a/lms/models/assignment_checkpoint.py b/lms/models/assignment_checkpoint.py new file mode 100644 index 0000000000..de2f86c1a4 --- /dev/null +++ b/lms/models/assignment_checkpoint.py @@ -0,0 +1,35 @@ +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from lms.db import Base +from lms.models._mixins import CreatedUpdatedMixin + + +class AssignmentCheckpoint(CreatedUpdatedMixin, Base): + """Hide & Reveal configuration for an assignment. + + The existence of a row marks the assignment as a Hide & Reveal assignment + (mirroring how a NULL auto_grading_config_id marks a non-auto-graded one). + + Reveal is whole-assignment: a single reveal_date applies to every group the + assignment is launched into. This LMS-side row is the source of truth for + that reveal state; on each launch the LMS fans it out into one h checkpoint + per (group, document) for server-side authorization. The LMS must persist + reveal_date here because lti_h.sync blindly re-sends current data on every + launch, so without it a re-sync would overwrite h's reveal_date back to NULL + and un-reveal an already-revealed assignment. + """ + + __tablename__ = "assignment_checkpoint" + + id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True) + + assignment_id: Mapped[int] = mapped_column( + sa.ForeignKey("assignment.id", ondelete="cascade"), index=True + ) + assignment = relationship("Assignment") + + reveal_date: Mapped[datetime | None] = mapped_column() + """When the instructor revealed the assignment; NULL until revealed.""" diff --git a/tests/factories/__init__.py b/tests/factories/__init__.py index 5c60757218..c48403ebd6 100644 --- a/tests/factories/__init__.py +++ b/tests/factories/__init__.py @@ -5,6 +5,7 @@ from tests.factories import requests_ as requests from tests.factories.application_instance import ApplicationInstance from tests.factories.assignment import Assignment, AutoGradingConfig +from tests.factories.assignment_checkpoint import AssignmentCheckpoint from tests.factories.assignment_grouping import AssignmentGrouping from tests.factories.assignment_membership import ( AssignmentMembership, diff --git a/tests/factories/assignment_checkpoint.py b/tests/factories/assignment_checkpoint.py new file mode 100644 index 0000000000..ff70f28fc9 --- /dev/null +++ b/tests/factories/assignment_checkpoint.py @@ -0,0 +1,12 @@ +from factory import SubFactory +from factory.alchemy import SQLAlchemyModelFactory + +from lms import models +from tests.factories.assignment import Assignment + + +class AssignmentCheckpoint(SQLAlchemyModelFactory): + class Meta: + model = models.AssignmentCheckpoint + + assignment = SubFactory(Assignment) diff --git a/tests/unit/lms/models/assignment_checkpoint_test.py b/tests/unit/lms/models/assignment_checkpoint_test.py new file mode 100644 index 0000000000..d663808ad6 --- /dev/null +++ b/tests/unit/lms/models/assignment_checkpoint_test.py @@ -0,0 +1,28 @@ +import pytest + +from lms.models import AssignmentCheckpoint +from tests import factories + + +class TestAssignmentCheckpoint: + def test_it(self, db_session, assignment): + db_session.commit() # Ensure our objects have ids + + checkpoint = AssignmentCheckpoint(assignment=assignment) + db_session.add(checkpoint) + + db_session.flush() + assert checkpoint.assignment == assignment + assert checkpoint.assignment_id == assignment.id + # reveal_date is NULL until the instructor reveals the assignment. + assert checkpoint.reveal_date is None + + def test_factory(self, db_session): + checkpoint = factories.AssignmentCheckpoint() + + db_session.flush() + assert checkpoint.assignment_id is not None + + @pytest.fixture + def assignment(self): + return factories.Assignment.create()