Skip to content

Commit 06ecbec

Browse files
Add reveal endpoint and assignment due date migration
1 parent ff0bce5 commit 06ecbec

23 files changed

Lines changed: 675 additions & 84 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""add_assignment_due_date
2+
3+
Revision ID: 2a45f5cb8e25
4+
Revises: af090ca7e73f
5+
"""
6+
from alembic import op
7+
import sqlalchemy as sa
8+
9+
10+
revision = '2a45f5cb8e25'
11+
down_revision = 'af090ca7e73f'
12+
13+
14+
def upgrade() -> None:
15+
op.add_column('assignment', sa.Column('due_date', sa.DateTime(), nullable=True))
16+
17+
18+
def downgrade() -> None:
19+
op.drop_column('assignment', 'due_date')

lms/models/assignment.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime
12
from enum import StrEnum
23

34
import sqlalchemy as sa
@@ -146,6 +147,9 @@ class Assignment(CreatedUpdatedMixin, Base):
146147
)
147148
auto_grading_config = relationship("AutoGradingConfig")
148149

150+
due_date: Mapped[datetime | None] = mapped_column()
151+
"""The due date for this assignment; NULL if not set."""
152+
149153
checkpoint = relationship(
150154
"AssignmentCheckpoint", uselist=False, back_populates="assignment"
151155
)

lms/product/canvas/product.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class Canvas(Product):
2525

2626
settings_key = "canvas"
2727

28-
use_toolbar_grading = False
28+
use_toolbar_grading = False
2929
"""We use SpeedGrader in canvas instead"""
3030
use_toolbar_editing = False
3131
"""Canvas allows re-deeplinking assignments. We don't support editing in our side."""

lms/resources/_js_config/__init__.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import functools
22
import re
3-
from datetime import timedelta
3+
from datetime import UTC, timedelta
44
from enum import Enum, StrEnum
55
from typing import Any
66
from urllib.parse import urlparse
@@ -471,6 +471,41 @@ def enable_instructor_dashboard_entry_point(self, assignment):
471471
}
472472
self._config["hypothesisClient"] = self._hypothesis_client
473473

474+
def enable_toolbar_checkpoint(self, assignment):
475+
toolbar_config = self._config.get("instructorToolbar", {})
476+
477+
toolbar_config["checkpoint"] = {
478+
"enabled": True,
479+
"revealed": assignment.checkpoint.reveal_date is not None,
480+
# reveal_date is stored as UTC without timezone info. Adding
481+
# UTC here so the ISO string includes +00:00 and the browser
482+
# can convert it to the user's local timezone.
483+
"revealDate": assignment.checkpoint.reveal_date.replace(tzinfo=UTC).isoformat()
484+
if assignment.checkpoint.reveal_date
485+
else None,
486+
# TODO: use actual due date once available; for now reuse reveal_date.
487+
"dueDate": assignment.checkpoint.reveal_date.replace(tzinfo=UTC).isoformat()
488+
if assignment.checkpoint.reveal_date
489+
else None,
490+
"revealUrl": self._request.route_url(
491+
"api.checkpoint.reveal", assignment_id=assignment.id
492+
),
493+
}
494+
self._config["instructorToolbar"] = toolbar_config
495+
496+
def enable_student_checkpoint(self, assignment):
497+
revealed = assignment.checkpoint.reveal_date is not None
498+
self._config["studentCheckpoint"] = {
499+
"hidden": not revealed,
500+
# TODO: use actual due date once available; for now reuse reveal_date.
501+
# reveal_date is stored as UTC without timezone info. Adding
502+
# UTC here so the ISO string includes +00:00 and the browser
503+
# can convert it to the user's local timezone.
504+
"dueDate": assignment.checkpoint.reveal_date.replace(tzinfo=UTC).isoformat()
505+
if assignment.checkpoint.reveal_date
506+
else None,
507+
}
508+
474509
def enable_toolbar_editing(self):
475510
toolbar_config = self._get_toolbar_config()
476511

lms/routes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ def includeme(config): # noqa: PLR0915
4545
)
4646

4747
config.add_route("api.sync", "/api/sync", request_method="POST")
48+
config.add_route(
49+
"api.checkpoint.reveal",
50+
"/api/assignments/{assignment_id}/checkpoint/reveal",
51+
request_method="POST",
52+
)
4853
config.add_route(
4954
"api.courses.group_sets.list", "/api/courses/{course_id}/group_sets"
5055
)

lms/services/h_api.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,14 @@ def sync_checkpoints(
221221
self,
222222
authority: str,
223223
checkpoints: list[dict],
224-
instructor_username: str | None = None,
224+
user: dict | None = None,
225225
) -> None:
226226
"""Sync checkpoint data to h via the bulk checkpoint endpoint.
227227
228228
:param authority: The h authority
229229
:param checkpoints: List of dicts with group_authority_provided_id,
230230
document_uri, and optionally reveal_date
231-
:param instructor_username: Optional username of an instructor to set
231+
:param user: Optional dict with 'username' and 'role' to set
232232
their lms_role in the group memberships
233233
"""
234234
if not checkpoints:
@@ -238,8 +238,8 @@ def sync_checkpoints(
238238
"authority": authority,
239239
"checkpoints": checkpoints,
240240
}
241-
if instructor_username:
242-
payload["instructor_username"] = instructor_username
241+
if user:
242+
payload["user"] = user
243243

244244
self._api_request(
245245
"POST",
@@ -250,6 +250,34 @@ def sync_checkpoints(
250250
},
251251
)
252252

253+
def reveal_checkpoints(
254+
self,
255+
authority: str,
256+
checkpoints: list[dict],
257+
) -> None:
258+
"""Reveal checkpoints in h, making annotations visible immediately.
259+
260+
:param authority: The h authority
261+
:param checkpoints: List of dicts with group_authority_provided_id
262+
and document_uri
263+
"""
264+
if not checkpoints:
265+
return
266+
267+
payload = {
268+
"authority": authority,
269+
"checkpoints": checkpoints,
270+
}
271+
272+
self._api_request(
273+
"POST",
274+
path="bulk/checkpoint/reveal",
275+
body=json.dumps(payload),
276+
headers={
277+
"Content-Type": "application/json",
278+
},
279+
)
280+
253281
def _api_request(self, method, path, body=None, headers=None, stream=False): # noqa: FBT002
254282
"""
255283
Send any kind of HTTP request to the h API and return the response.

lms/services/lti_h.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ def checkpoint_sync_data(assignment: Assignment | None, lti_user) -> dict | None
1414
if not (assignment and assignment.checkpoint):
1515
return None
1616

17+
role = "instructor" if lti_user.is_instructor else "student"
1718
return {
1819
"document_uri": assignment.document_url,
1920
"reveal_date": assignment.checkpoint.reveal_date.isoformat()
2021
if assignment.checkpoint.reveal_date
2122
else None,
22-
"instructor_username": lti_user.h_user.username
23-
if lti_user.is_instructor
24-
else None,
23+
"user": {
24+
"username": lti_user.h_user.username,
25+
"role": role,
26+
},
2527
}
2628

2729

@@ -121,7 +123,7 @@ def _sync_checkpoints(self, groupings: list[Grouping], checkpoint_data: dict):
121123
self._h_api.sync_checkpoints(
122124
authority=self._authority,
123125
checkpoints=checkpoints,
124-
instructor_username=checkpoint_data.get("instructor_username"),
126+
user=checkpoint_data.get("user"),
125127
)
126128

127129
def _group_upsert(self, grouping, ref):

lms/static/scripts/frontend_apps/components/BasicLTILaunchApp.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { apiCall } from '../utils/api';
1515
import ContentFrame from './ContentFrame';
1616
import InstructorToolbar from './InstructorToolbar';
1717
import LaunchErrorDialog from './LaunchErrorDialog';
18+
import StudentCheckpointBar from './StudentCheckpointBar';
1819

1920
/**
2021
* Error states managed by this component that can arise during assignment
@@ -346,6 +347,7 @@ export default function BasicLTILaunchApp() {
346347
data-testid="content-wrapper"
347348
>
348349
<InstructorToolbar />
350+
<StudentCheckpointBar />
349351
<ContentFrame url={contentURL ?? ''} />
350352
</div>
351353
{errorState && (
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { formatDateTime } from '@hypothesis/frontend-shared';
2+
3+
import type { CheckpointConfig } from '../config';
4+
import RevealAnnotationsButton from './RevealAnnotationsButton';
5+
6+
export default function CheckpointBar({
7+
checkpoint,
8+
}: {
9+
checkpoint: CheckpointConfig;
10+
}) {
11+
return (
12+
<div className="p-2 flex items-center border-t">
13+
<div className="text-sm" data-testid="checkpoint-type">
14+
<div className="font-semibold">Checkpoint:</div>
15+
<div>Manual</div>
16+
</div>
17+
{checkpoint.dueDate && (
18+
<div className="text-sm mx-auto" data-testid="checkpoint-due-date">
19+
<div className="font-semibold">Due Date:</div>
20+
<div>{formatDateTime(checkpoint.dueDate)}</div>
21+
</div>
22+
)}
23+
<div className="ml-auto">
24+
<RevealAnnotationsButton checkpoint={checkpoint} />
25+
</div>
26+
</div>
27+
);
28+
}

lms/static/scripts/frontend_apps/components/FilePickerApp.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,6 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
193193
formFields,
194194
promptForTitle,
195195
promptForGradable,
196-
hideAndRevealEnabled,
197196
},
198197
} = useConfig(['api', 'filePicker']);
199198

@@ -242,9 +241,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
242241
promptForTitle ||
243242
promptForGradable ||
244243
autoGradingEnabled ||
245-
// Show the details screen for new assignments so the Hide & Reveal option
246-
// can be offered, but only where the feature is enabled.
247-
(!isEditing && !!hideAndRevealEnabled);
244+
!isEditing; // Always show details for new assignments (checkpoint option)
248245

249246
let currentStep: PickerStep;
250247
if (editingContent) {
@@ -308,9 +305,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
308305
const data: DeepLinkingAPIData = {
309306
...deepLinkingAPI.data,
310307
auto_grading_config: autoGradingConfigToSave,
311-
...(hideAndRevealEnabled
312-
? { checkpoint_enabled: checkpointEnabled }
313-
: {}),
308+
checkpoint_enabled: checkpointEnabled,
314309
content,
315310
group_set: groupConfig.useGroupSet ? groupConfig.groupSet : null,
316311
title,
@@ -339,7 +334,6 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
339334
[
340335
authToken,
341336
checkpointEnabled,
342-
hideAndRevealEnabled,
343337
deepLinkingFields,
344338
deepLinkingAPI,
345339
groupConfig.groupSet,
@@ -561,7 +555,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
561555
/>
562556
</>
563557
)}
564-
{!isEditing && hideAndRevealEnabled && (
558+
{!isEditing && (
565559
<>
566560
<div className="sm:col-span-2 border-b" />
567561
<PanelLabel isCurrentStep verticalAlign="center">
@@ -643,7 +637,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
643637
formFields={formFields}
644638
groupSet={groupConfig.useGroupSet ? groupConfig.groupSet : null}
645639
autoGradingConfig={autoGradingConfigToSave}
646-
{...(hideAndRevealEnabled ? { checkpointEnabled } : {})}
640+
checkpointEnabled={checkpointEnabled}
647641
/>
648642
)
649643
}

0 commit comments

Comments
 (0)