Skip to content
Closed
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
19 changes: 19 additions & 0 deletions lms/migrations/versions/2a45f5cb8e25_add_assignment_due_date.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Add assignment due date.

Revision ID: 2a45f5cb8e25
Revises: af090ca7e73f
"""

import sqlalchemy as sa
from alembic import op

revision = "2a45f5cb8e25"
down_revision = "af090ca7e73f"


def upgrade() -> None:
op.add_column("assignment", sa.Column("due_date", sa.DateTime(), nullable=True))


def downgrade() -> None:
op.drop_column("assignment", "due_date")
8 changes: 8 additions & 0 deletions lms/models/assignment.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from enum import StrEnum

import sqlalchemy as sa
Expand Down Expand Up @@ -146,6 +147,13 @@ class Assignment(CreatedUpdatedMixin, Base):
)
auto_grading_config = relationship("AutoGradingConfig")

due_date: Mapped[datetime | None] = mapped_column()
"""The due date for this assignment; NULL if not set."""

checkpoint = relationship(
"AssignmentCheckpoint", uselist=False, back_populates="assignment"
)

__table_args__ = (
sa.UniqueConstraint("resource_link_id", "tool_consumer_instance_guid"),
sa.Index(
Expand Down
2 changes: 1 addition & 1 deletion lms/models/assignment_checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class AssignmentCheckpoint(CreatedUpdatedMixin, Base):
assignment_id: Mapped[int] = mapped_column(
sa.ForeignKey("assignment.id", ondelete="cascade"), index=True
)
assignment = relationship("Assignment")
assignment = relationship("Assignment", back_populates="checkpoint")

reveal_date: Mapped[datetime | None] = mapped_column()
"""When the instructor revealed the assignment; NULL until revealed."""
10 changes: 7 additions & 3 deletions lms/product/canvas/_plugin/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,17 @@ def get_assignment_configuration(
group_set_id=request.params.get("group_set"),
)

if auto_grading_config := self.get_deep_linked_assignment_configuration(
request
).get("auto_grading_config"):
deep_linked_config = self.get_deep_linked_assignment_configuration(request)

if auto_grading_config := deep_linked_config.get("auto_grading_config"):
# Auto grading is a complex structure, deserialize it beforehand
assignment_config["auto_grading_config"] = cast(
"AutoGradingConfig", json.loads(auto_grading_config)
)

if deep_linked_config.get("checkpoint_enabled") in ("true", True):
assignment_config["checkpoint_enabled"] = True

return assignment_config

@lru_cache(1) # noqa: B019
Expand Down Expand Up @@ -150,6 +153,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict:
possible_parameters = [
"group_set",
"auto_grading_config",
"checkpoint_enabled",
# VS, legacy method
"vitalsource_book",
"book_id",
Expand Down
8 changes: 8 additions & 0 deletions lms/product/plugin/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class AssignmentConfig(TypedDict):
document_url: str | None
group_set_id: str | None
auto_grading_config: NotRequired[AutoGradingConfig | None]
checkpoint_enabled: NotRequired[bool]


class DeepLinkingPromptForGradableMixin:
Expand Down Expand Up @@ -125,6 +126,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict:
"group_set",
"deep_linking_uuid",
"auto_grading_config",
"checkpoint_enabled",
]

for param in possible_parameters:
Expand All @@ -148,6 +150,9 @@ def _assignment_config_from_assignment(assignment: Assignment) -> AssignmentConf
if auto_grading_config := assignment.auto_grading_config:
config["auto_grading_config"] = auto_grading_config.asdict()

if assignment.checkpoint:
config["checkpoint_enabled"] = True

return config

@staticmethod
Expand All @@ -163,4 +168,7 @@ def _assignment_config_from_deep_linked_config(
if auto_grading_config := deep_linked_config.get("auto_grading_config"):
config["auto_grading_config"] = json.loads(auto_grading_config)

if deep_linked_config.get("checkpoint_enabled") in ("true", True):
config["checkpoint_enabled"] = True

return config
39 changes: 38 additions & 1 deletion lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import functools
import re
from datetime import timedelta
from datetime import UTC, timedelta
from enum import Enum, StrEnum
from typing import Any
from urllib.parse import urlparse
Expand Down Expand Up @@ -471,6 +471,43 @@ def enable_instructor_dashboard_entry_point(self, assignment):
}
self._config["hypothesisClient"] = self._hypothesis_client

def enable_toolbar_checkpoint(self, assignment):
toolbar_config = self._config.get("instructorToolbar", {})

toolbar_config["checkpoint"] = {
"enabled": True,
"revealed": assignment.checkpoint.reveal_date is not None,
# reveal_date is stored as UTC without timezone info. Adding
# UTC here so the ISO string includes +00:00 and the browser
# can convert it to the user's local timezone.
"revealDate": assignment.checkpoint.reveal_date.replace(
tzinfo=UTC
).isoformat()
if assignment.checkpoint.reveal_date
else None,
# Use actual due date once available; for now reuse reveal_date.
"dueDate": assignment.checkpoint.reveal_date.replace(tzinfo=UTC).isoformat()
if assignment.checkpoint.reveal_date
else None,
"revealUrl": self._request.route_url(
"api.checkpoint.reveal", assignment_id=assignment.id
),
}
self._config["instructorToolbar"] = toolbar_config

def enable_student_checkpoint(self, assignment):
revealed = assignment.checkpoint.reveal_date is not None
self._config["studentCheckpoint"] = {
"hidden": not revealed,
# Use actual due date once available; for now reuse reveal_date.
# reveal_date is stored as UTC without timezone info. Adding
# UTC here so the ISO string includes +00:00 and the browser
# can convert it to the user's local timezone.
"dueDate": assignment.checkpoint.reveal_date.replace(tzinfo=UTC).isoformat()
if assignment.checkpoint.reveal_date
else None,
}

def enable_toolbar_editing(self):
toolbar_config = self._get_toolbar_config()

Expand Down
5 changes: 5 additions & 0 deletions lms/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ def includeme(config): # noqa: PLR0915
)

config.add_route("api.sync", "/api/sync", request_method="POST")
config.add_route(
"api.checkpoint.reveal",
"/api/assignments/{assignment_id}/checkpoint/reveal",
request_method="POST",
)
config.add_route(
"api.courses.group_sets.list", "/api/courses/{course_id}/group_sets"
)
Expand Down
29 changes: 28 additions & 1 deletion lms/services/assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from lms.models import (
Assignment,
AssignmentCheckpoint,
AssignmentGrouping,
AssignmentMembership,
AutoGradingConfig,
Expand Down Expand Up @@ -62,6 +63,7 @@ def update_assignment( # noqa: PLR0913
group_set_id,
course: Course,
auto_grading_config: dict | None = None,
checkpoint_enabled: bool = False, # noqa: FBT001, FBT002
):
"""Update an existing assignment."""
if self._misc_plugin.is_speed_grader_launch(request):
Expand Down Expand Up @@ -96,6 +98,7 @@ def update_assignment( # noqa: PLR0913

assignment.course_id = course.id
self._update_auto_grading_config(assignment, auto_grading_config)
self._update_checkpoint(assignment, checkpoint_enabled)

return assignment

Expand Down Expand Up @@ -151,6 +154,7 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non
document_url = assignment_config.get("document_url")
group_set_id = assignment_config.get("group_set_id")
auto_grading_config = assignment_config.get("auto_grading_config")
checkpoint_enabled = assignment_config.get("checkpoint_enabled", False)

if not document_url:
# We can't find a document_url, we shouldn't try to create an
Expand Down Expand Up @@ -181,7 +185,13 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non
# It often will be the same one while launching the assignment again but
# it might for example be an updated deep linked URL or similar.
return self.update_assignment(
request, assignment, document_url, group_set_id, course, auto_grading_config
request,
assignment,
document_url,
group_set_id,
course,
auto_grading_config,
checkpoint_enabled=checkpoint_enabled,
)

def upsert_assignment_membership(
Expand Down Expand Up @@ -373,6 +383,23 @@ def get_assignment_sections(self, assignment) -> Sequence[Grouping]:
.order_by(Grouping.lms_name.asc())
).all()

def _update_checkpoint(
self,
assignment: Assignment,
checkpoint_enabled: bool, # noqa: FBT001
) -> None:
"""Create a checkpoint for an assignment if enabled and not already present.

Checkpoints can only be set at creation time -- once created they
are never removed by an edit, and calling this with
checkpoint_enabled=False on an assignment that already has a
checkpoint is a no-op.
"""
if checkpoint_enabled and not assignment.checkpoint:
checkpoint = AssignmentCheckpoint(assignment=assignment)
self._db.add(checkpoint)
assignment.checkpoint = checkpoint

def _update_auto_grading_config(
self, assignment: Assignment, auto_grading_config: dict | None
) -> None:
Expand Down
61 changes: 61 additions & 0 deletions lms/services/h_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,67 @@ def get_annotation_counts(
)
return response.json()

def sync_checkpoints(
self,
authority: str,
checkpoints: list[dict],
user: dict | None = None,
) -> None:
"""Sync checkpoint data to h via the bulk checkpoint endpoint.

:param authority: The h authority
:param checkpoints: List of dicts with group_authority_provided_id,
document_uri, and optionally reveal_date
:param user: Optional dict with 'username' and 'role' to set
their lms_role in the group memberships
"""
if not checkpoints:
return

payload: dict = {
"authority": authority,
"checkpoints": checkpoints,
}
if user:
payload["user"] = user

self._api_request(
"POST",
path="bulk/checkpoint",
body=json.dumps(payload),
headers={
"Content-Type": "application/json",
},
)

def reveal_checkpoints(
self,
authority: str,
checkpoints: list[dict],
) -> None:
"""Reveal checkpoints in h, making annotations visible immediately.

:param authority: The h authority
:param checkpoints: List of dicts with group_authority_provided_id
and document_uri
"""
if not checkpoints:
return

payload = {
"authority": authority,
"checkpoints": checkpoints,
}

self._api_request(
"POST",
path="bulk/checkpoint/reveal",
body=json.dumps(payload),
headers={
"Content-Type": "application/json",
},
)

def _api_request(self, method, path, body=None, headers=None, stream=False): # noqa: FBT002
"""
Send any kind of HTTP request to the h API and return the response.
Expand Down
Loading
Loading