Skip to content

Commit 84f9896

Browse files
Add assignment creation flow
1 parent ca0c44e commit 84f9896

14 files changed

Lines changed: 310 additions & 17 deletions

File tree

lms/models/assignment.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ class Assignment(CreatedUpdatedMixin, Base):
146146
)
147147
auto_grading_config = relationship("AutoGradingConfig")
148148

149+
checkpoint = relationship(
150+
"AssignmentCheckpoint", uselist=False, back_populates="assignment"
151+
)
152+
149153
__table_args__ = (
150154
sa.UniqueConstraint("resource_link_id", "tool_consumer_instance_guid"),
151155
sa.Index(

lms/models/assignment_checkpoint.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class AssignmentCheckpoint(CreatedUpdatedMixin, Base):
2929
assignment_id: Mapped[int] = mapped_column(
3030
sa.ForeignKey("assignment.id", ondelete="cascade"), index=True
3131
)
32-
assignment = relationship("Assignment")
32+
assignment = relationship("Assignment", back_populates="checkpoint")
3333

3434
reveal_date: Mapped[datetime | None] = mapped_column()
3535
"""When the instructor revealed the assignment; NULL until revealed."""

lms/product/canvas/_plugin/misc.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,17 @@ def get_assignment_configuration(
5959
group_set_id=request.params.get("group_set"),
6060
)
6161

62-
if auto_grading_config := self.get_deep_linked_assignment_configuration(
63-
request
64-
).get("auto_grading_config"):
62+
deep_linked_config = self.get_deep_linked_assignment_configuration(request)
63+
64+
if auto_grading_config := deep_linked_config.get("auto_grading_config"):
6565
# Auto grading is a complex structure, deserialize it beforehand
6666
assignment_config["auto_grading_config"] = cast(
6767
"AutoGradingConfig", json.loads(auto_grading_config)
6868
)
6969

70+
if deep_linked_config.get("checkpoint_enabled") in ("true", True):
71+
assignment_config["checkpoint_enabled"] = True
72+
7073
return assignment_config
7174

7275
@lru_cache(1) # noqa: B019
@@ -150,6 +153,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict:
150153
possible_parameters = [
151154
"group_set",
152155
"auto_grading_config",
156+
"checkpoint_enabled",
153157
# VS, legacy method
154158
"vitalsource_book",
155159
"book_id",

lms/product/plugin/misc.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class AssignmentConfig(TypedDict):
1515
document_url: str | None
1616
group_set_id: str | None
1717
auto_grading_config: NotRequired[AutoGradingConfig | None]
18+
checkpoint_enabled: NotRequired[bool]
1819

1920

2021
class DeepLinkingPromptForGradableMixin:
@@ -125,6 +126,7 @@ def get_deep_linked_assignment_configuration(self, request) -> dict:
125126
"group_set",
126127
"deep_linking_uuid",
127128
"auto_grading_config",
129+
"checkpoint_enabled",
128130
]
129131

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

153+
if assignment.checkpoint:
154+
config["checkpoint_enabled"] = True
155+
151156
return config
152157

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

171+
if deep_linked_config.get("checkpoint_enabled") in ("true", True):
172+
config["checkpoint_enabled"] = True
173+
166174
return config

lms/services/assignment.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from lms.models import (
88
Assignment,
9+
AssignmentCheckpoint,
910
AssignmentGrouping,
1011
AssignmentMembership,
1112
AutoGradingConfig,
@@ -62,6 +63,7 @@ def update_assignment( # noqa: PLR0913
6263
group_set_id,
6364
course: Course,
6465
auto_grading_config: dict | None = None,
66+
checkpoint_enabled: bool = False,
6567
):
6668
"""Update an existing assignment."""
6769
if self._misc_plugin.is_speed_grader_launch(request):
@@ -96,6 +98,7 @@ def update_assignment( # noqa: PLR0913
9698

9799
assignment.course_id = course.id
98100
self._update_auto_grading_config(assignment, auto_grading_config)
101+
self._update_checkpoint(assignment, checkpoint_enabled)
99102

100103
return assignment
101104

@@ -151,6 +154,7 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non
151154
document_url = assignment_config.get("document_url")
152155
group_set_id = assignment_config.get("group_set_id")
153156
auto_grading_config = assignment_config.get("auto_grading_config")
157+
checkpoint_enabled = assignment_config.get("checkpoint_enabled", False)
154158

155159
if not document_url:
156160
# We can't find a document_url, we shouldn't try to create an
@@ -181,7 +185,13 @@ def get_assignment_for_launch(self, request, course: Course) -> Assignment | Non
181185
# It often will be the same one while launching the assignment again but
182186
# it might for example be an updated deep linked URL or similar.
183187
return self.update_assignment(
184-
request, assignment, document_url, group_set_id, course, auto_grading_config
188+
request,
189+
assignment,
190+
document_url,
191+
group_set_id,
192+
course,
193+
auto_grading_config,
194+
checkpoint_enabled=checkpoint_enabled,
185195
)
186196

187197
def upsert_assignment_membership(
@@ -373,6 +383,21 @@ def get_assignment_sections(self, assignment) -> Sequence[Grouping]:
373383
.order_by(Grouping.lms_name.asc())
374384
).all()
375385

386+
def _update_checkpoint(
387+
self, assignment: Assignment, checkpoint_enabled: bool
388+
) -> None:
389+
"""Create a checkpoint for an assignment if enabled and not already present.
390+
391+
Checkpoints can only be set at creation time -- once created they
392+
are never removed by an edit, and calling this with
393+
checkpoint_enabled=False on an assignment that already has a
394+
checkpoint is a no-op.
395+
"""
396+
if checkpoint_enabled and not assignment.checkpoint:
397+
checkpoint = AssignmentCheckpoint(assignment=assignment)
398+
self._db.add(checkpoint)
399+
assignment.checkpoint = checkpoint
400+
376401
def _update_auto_grading_config(
377402
self, assignment: Assignment, auto_grading_config: dict | None
378403
) -> None:

lms/services/h_api.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,39 @@ def get_annotation_counts(
217217
)
218218
return response.json()
219219

220+
def sync_checkpoints(
221+
self,
222+
authority: str,
223+
checkpoints: list[dict],
224+
instructor_username: str | None = None,
225+
) -> None:
226+
"""Sync checkpoint data to h via the bulk checkpoint endpoint.
227+
228+
:param authority: The h authority
229+
:param checkpoints: List of dicts with group_authority_provided_id,
230+
document_uri, and optionally reveal_date
231+
:param instructor_username: Optional username of an instructor to set
232+
their lms_role in the group memberships
233+
"""
234+
if not checkpoints:
235+
return
236+
237+
payload = {
238+
"authority": authority,
239+
"checkpoints": checkpoints,
240+
}
241+
if instructor_username:
242+
payload["instructor_username"] = instructor_username
243+
244+
self._api_request(
245+
"POST",
246+
path="bulk/checkpoint",
247+
body=json.dumps(payload),
248+
headers={
249+
"Content-Type": "application/json",
250+
},
251+
)
252+
220253
def _api_request(self, method, path, body=None, headers=None, stream=False): # noqa: FBT002
221254
"""
222255
Send any kind of HTTP request to the h API and return the response.

lms/services/lti_h.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ def __init__(self, _context, request) -> None:
2525
self._h_api: HAPI = request.find_service(HAPI)
2626
self._group_info_service = request.find_service(name="group_info")
2727

28-
def sync(self, groupings: list[Grouping], group_info_params: dict):
28+
def sync(
29+
self,
30+
groupings: list[Grouping],
31+
group_info_params: dict,
32+
checkpoint_data: dict | None = None,
33+
):
2934
"""
3035
Sync standard data to h for an LTI launch with the provided groups.
3136
@@ -34,6 +39,8 @@ def sync(self, groupings: list[Grouping], group_info_params: dict):
3439
3540
:param groupings: groupings to sync to H
3641
:param group_info_params: params to add for each in `GroupInfo`
42+
:param checkpoint_data: optional dict with document_uri and reveal_date
43+
to sync a checkpoint for each grouping
3744
3845
:raise HTTPInternalServerError: if we can't sync to h for any reason
3946
:raise ApplicationInstanceNotFound: if
@@ -47,6 +54,9 @@ def sync(self, groupings: list[Grouping], group_info_params: dict):
4754
grouping=grouping, params=group_info_params
4855
)
4956

57+
if checkpoint_data:
58+
self._sync_checkpoints(groupings, checkpoint_data)
59+
5060
def _yield_commands(self, groupings):
5161
# Note! - Syncing a user to `h` currently has an implication for
5262
# reporting and so billing and will as long as our billing metric is
@@ -77,6 +87,22 @@ def _user_upsert(self, h_user, ref="user_0"):
7787
ref,
7888
)
7989

90+
def _sync_checkpoints(self, groupings: list[Grouping], checkpoint_data: dict):
91+
checkpoints = [
92+
{
93+
"group_authority_provided_id": grouping.authority_provided_id,
94+
"document_uri": checkpoint_data["document_uri"],
95+
"reveal_date": checkpoint_data.get("reveal_date"),
96+
}
97+
for grouping in groupings
98+
]
99+
100+
self._h_api.sync_checkpoints(
101+
authority=self._authority,
102+
checkpoints=checkpoints,
103+
instructor_username=checkpoint_data.get("instructor_username"),
104+
)
105+
80106
def _group_upsert(self, grouping, ref):
81107
return CommandBuilder.group.upsert(
82108
{

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,9 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
226226
return autoGradingEnabled && enabled ? rest : null;
227227
}, [autoGradingConfig, autoGradingEnabled]);
228228

229+
// Whether this assignment should have a checkpoint.
230+
const [checkpointEnabled, setCheckpointEnabled] = useState(false);
231+
229232
// Flag indicating if we are editing content that was previously selected.
230233
const [editingContent, setEditingContent] = useState(false);
231234
// True if we are editing an existing assignment configuration.
@@ -237,7 +240,8 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
237240
enableGroupConfig ||
238241
promptForTitle ||
239242
promptForGradable ||
240-
autoGradingEnabled;
243+
autoGradingEnabled ||
244+
!isEditing; // Always show details for new assignments (checkpoint option)
241245

242246
let currentStep: PickerStep;
243247
if (editingContent) {
@@ -301,6 +305,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
301305
const data: DeepLinkingAPIData = {
302306
...deepLinkingAPI.data,
303307
auto_grading_config: autoGradingConfigToSave,
308+
checkpoint_enabled: checkpointEnabled,
304309
content,
305310
group_set: groupConfig.useGroupSet ? groupConfig.groupSet : null,
306311
title,
@@ -328,6 +333,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
328333
},
329334
[
330335
authToken,
336+
checkpointEnabled,
331337
deepLinkingFields,
332338
deepLinkingAPI,
333339
groupConfig.groupSet,
@@ -549,6 +555,27 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
549555
/>
550556
</>
551557
)}
558+
{!isEditing && (
559+
<>
560+
<div className="sm:col-span-2 border-b" />
561+
<PanelLabel isCurrentStep verticalAlign="center">
562+
Hide &amp; Reveal
563+
</PanelLabel>
564+
<label className="flex items-center gap-x-2">
565+
<input
566+
type="checkbox"
567+
data-testid="checkpoint-enabled"
568+
checked={checkpointEnabled}
569+
onChange={event =>
570+
setCheckpointEnabled(
571+
(event.target as HTMLInputElement).checked,
572+
)
573+
}
574+
/>
575+
Enable checkpoint
576+
</label>
577+
</>
578+
)}
552579
{enableGroupConfig && (
553580
<>
554581
<div className="sm:col-span-2 border-b" />
@@ -610,6 +637,7 @@ export default function FilePickerApp({ onSubmit }: FilePickerAppProps) {
610637
formFields={formFields}
611638
groupSet={groupConfig.useGroupSet ? groupConfig.groupSet : null}
612639
autoGradingConfig={autoGradingConfigToSave}
640+
checkpointEnabled={checkpointEnabled}
613641
/>
614642
)
615643
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export type FilePickerFormFieldsProps = {
2424

2525
/** Auto-grading configuration for assignments where it is enabled */
2626
autoGradingConfig: AutoGradingConfig | null;
27+
28+
/** Whether hide & reveal checkpoint is enabled for this assignment */
29+
checkpointEnabled: boolean;
2730
};
2831

2932
/**
@@ -38,6 +41,7 @@ export default function FilePickerFormFields({
3841
formFields,
3942
groupSet,
4043
autoGradingConfig,
44+
checkpointEnabled,
4145
}: FilePickerFormFieldsProps) {
4246
return (
4347
<>
@@ -58,6 +62,9 @@ export default function FilePickerFormFields({
5862
value={JSON.stringify(autoGradingConfig)}
5963
/>
6064
)}
65+
{checkpointEnabled && (
66+
<input type="hidden" name="checkpoint_enabled" value="true" />
67+
)}
6168
</>
6269
);
6370
}

lms/validation/_lti_launch_params.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ class ConfigureAssignmentSchema(_CommonLTILaunchSchema):
185185
auto_grading_config = fields.Nested(
186186
AutoGradingConfigSchema, required=False, allow_none=True
187187
)
188+
checkpoint_enabled = fields.Bool(
189+
required=False, load_default=False, truthy={"true", "1"}, falsy={"false", "0", ""}
190+
)
188191

189192
@pre_load
190193
def _load_auto_grading_config(self, data, **_kwargs):

0 commit comments

Comments
 (0)