ff-ltitoolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
- ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
- ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
- ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- ltitoolkit/__init__.py +20 -0
- ltitoolkit/adapters/__init__.py +11 -0
- ltitoolkit/adapters/brightspace/__init__.py +35 -0
- ltitoolkit/adapters/brightspace/client.py +176 -0
- ltitoolkit/adapters/canvas/__init__.py +27 -0
- ltitoolkit/adapters/canvas/client.py +142 -0
- ltitoolkit/advantage/__init__.py +9 -0
- ltitoolkit/advantage/service.py +96 -0
- ltitoolkit/core/__init__.py +19 -0
- ltitoolkit/core/actions.py +6 -0
- ltitoolkit/core/assignments_grades.py +300 -0
- ltitoolkit/core/contrib/__init__.py +0 -0
- ltitoolkit/core/contrib/django/__init__.py +5 -0
- ltitoolkit/core/contrib/django/cookie.py +56 -0
- ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
- ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
- ltitoolkit/core/contrib/django/message_launch.py +39 -0
- ltitoolkit/core/contrib/django/oidc_login.py +41 -0
- ltitoolkit/core/contrib/django/redirect.py +34 -0
- ltitoolkit/core/contrib/django/request.py +32 -0
- ltitoolkit/core/contrib/django/session.py +5 -0
- ltitoolkit/core/contrib/flask/__init__.py +7 -0
- ltitoolkit/core/contrib/flask/cookie.py +34 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
- ltitoolkit/core/contrib/flask/message_launch.py +32 -0
- ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
- ltitoolkit/core/contrib/flask/redirect.py +34 -0
- ltitoolkit/core/contrib/flask/request.py +40 -0
- ltitoolkit/core/contrib/flask/session.py +5 -0
- ltitoolkit/core/contrib/py.typed +0 -0
- ltitoolkit/core/cookie.py +17 -0
- ltitoolkit/core/cookies_allowed_check.py +151 -0
- ltitoolkit/core/course_groups.py +115 -0
- ltitoolkit/core/deep_link.py +100 -0
- ltitoolkit/core/deep_link_resource.py +96 -0
- ltitoolkit/core/deployment.py +13 -0
- ltitoolkit/core/exception.py +16 -0
- ltitoolkit/core/grade.py +143 -0
- ltitoolkit/core/launch_data_storage/__init__.py +0 -0
- ltitoolkit/core/launch_data_storage/base.py +75 -0
- ltitoolkit/core/launch_data_storage/cache.py +43 -0
- ltitoolkit/core/launch_data_storage/session.py +29 -0
- ltitoolkit/core/lineitem.py +205 -0
- ltitoolkit/core/message_launch.py +828 -0
- ltitoolkit/core/message_validators/__init__.py +13 -0
- ltitoolkit/core/message_validators/abstract.py +25 -0
- ltitoolkit/core/message_validators/deep_link.py +34 -0
- ltitoolkit/core/message_validators/privacy_launch.py +40 -0
- ltitoolkit/core/message_validators/resource_message.py +21 -0
- ltitoolkit/core/message_validators/submission_review.py +45 -0
- ltitoolkit/core/names_roles.py +97 -0
- ltitoolkit/core/oidc_login.py +275 -0
- ltitoolkit/core/py.typed +0 -0
- ltitoolkit/core/redirect.py +24 -0
- ltitoolkit/core/registration.py +119 -0
- ltitoolkit/core/request.py +17 -0
- ltitoolkit/core/roles.py +109 -0
- ltitoolkit/core/service_connector.py +144 -0
- ltitoolkit/core/session.py +70 -0
- ltitoolkit/core/tool_config/__init__.py +4 -0
- ltitoolkit/core/tool_config/abstract.py +117 -0
- ltitoolkit/core/tool_config/dict.py +253 -0
- ltitoolkit/core/tool_config/json_file.py +100 -0
- ltitoolkit/core/tool_config/py.typed +0 -0
- ltitoolkit/core/utils.py +10 -0
- ltitoolkit/dynamic_registration/__init__.py +39 -0
- ltitoolkit/dynamic_registration/models.py +192 -0
- ltitoolkit/dynamic_registration/service.py +156 -0
- ltitoolkit/dynamic_registration/store.py +40 -0
- ltitoolkit/dynamic_registration/tool_conf.py +102 -0
- ltitoolkit/exceptions.py +42 -0
- ltitoolkit/fastapi/__init__.py +30 -0
- ltitoolkit/fastapi/cookie.py +53 -0
- ltitoolkit/fastapi/dynamic_registration.py +40 -0
- ltitoolkit/fastapi/message_launch.py +60 -0
- ltitoolkit/fastapi/oidc_login.py +47 -0
- ltitoolkit/fastapi/redirect.py +54 -0
- ltitoolkit/fastapi/request.py +77 -0
- ltitoolkit/fastapi/session.py +13 -0
- ltitoolkit/http.py +80 -0
- ltitoolkit/token/__init__.py +20 -0
- ltitoolkit/token/cache.py +47 -0
- ltitoolkit/token/service.py +165 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""A clean facade over the LTI Advantage services of a validated launch.
|
|
2
|
+
|
|
3
|
+
Wraps a validated ``MessageLaunch`` and exposes the two portable, every-LMS
|
|
4
|
+
services with explicit availability guards and a couple of ergonomic helpers:
|
|
5
|
+
|
|
6
|
+
- **NRPS** — the course roster (paginated transparently by the core).
|
|
7
|
+
- **AGS** — read/write grades for *this tool's* activities.
|
|
8
|
+
|
|
9
|
+
It does not reimplement the spec logic (that lives in the vendored core); it
|
|
10
|
+
gives applications a small, typed, documented surface so they never import from
|
|
11
|
+
``ltitoolkit.core`` directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import typing as t
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
from ..core.grade import Grade
|
|
20
|
+
from ..exceptions import LtiToolkitError
|
|
21
|
+
|
|
22
|
+
if t.TYPE_CHECKING:
|
|
23
|
+
from ..core.assignments_grades import AssignmentsGradesService
|
|
24
|
+
from ..core.lineitem import LineItem
|
|
25
|
+
from ..core.message_launch import MessageLaunch
|
|
26
|
+
from ..core.names_roles import NamesRolesProvisioningService, TMember
|
|
27
|
+
from ..core.service_connector import TServiceConnectorResponse
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AdvantageServiceUnavailable(LtiToolkitError):
|
|
31
|
+
"""Raised when a launch did not grant the requested Advantage service."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LaunchAdvantage:
|
|
35
|
+
"""Ergonomic access to AGS/NRPS for a single validated launch."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message_launch: MessageLaunch) -> None:
|
|
38
|
+
self._launch = message_launch
|
|
39
|
+
|
|
40
|
+
# -- Names & Role Provisioning (roster) --------------------------------
|
|
41
|
+
|
|
42
|
+
def has_names_and_roles(self) -> bool:
|
|
43
|
+
return self._launch.has_nrps()
|
|
44
|
+
|
|
45
|
+
def names_and_roles(self) -> NamesRolesProvisioningService:
|
|
46
|
+
if not self.has_names_and_roles():
|
|
47
|
+
raise AdvantageServiceUnavailable(
|
|
48
|
+
"This launch does not include the Names and Role Provisioning Service"
|
|
49
|
+
)
|
|
50
|
+
return self._launch.get_nrps()
|
|
51
|
+
|
|
52
|
+
def get_roster(self, resource_link_id: str | None = None) -> list[TMember]:
|
|
53
|
+
"""Return all course members (every page), optionally filtered by resource link."""
|
|
54
|
+
return self.names_and_roles().get_members(resource_link_id)
|
|
55
|
+
|
|
56
|
+
# -- Assignment & Grade Services (grades) ------------------------------
|
|
57
|
+
|
|
58
|
+
def has_grades(self) -> bool:
|
|
59
|
+
return self._launch.has_ags()
|
|
60
|
+
|
|
61
|
+
def grades(self) -> AssignmentsGradesService:
|
|
62
|
+
if not self.has_grades():
|
|
63
|
+
raise AdvantageServiceUnavailable(
|
|
64
|
+
"This launch does not include the Assignment and Grade Services"
|
|
65
|
+
)
|
|
66
|
+
return self._launch.get_ags()
|
|
67
|
+
|
|
68
|
+
def submit_score(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
user_id: str,
|
|
72
|
+
score_given: float,
|
|
73
|
+
score_maximum: float,
|
|
74
|
+
activity_progress: str = "Completed",
|
|
75
|
+
grading_progress: str = "FullyGraded",
|
|
76
|
+
comment: str | None = None,
|
|
77
|
+
timestamp: str | None = None,
|
|
78
|
+
lineitem: LineItem | None = None,
|
|
79
|
+
) -> TServiceConnectorResponse:
|
|
80
|
+
"""Push a score back to the gradebook for ``user_id``.
|
|
81
|
+
|
|
82
|
+
Defaults to the line item carried by the launch; pass ``lineitem`` to
|
|
83
|
+
target a specific one. ``timestamp`` defaults to now (ISO 8601, UTC).
|
|
84
|
+
"""
|
|
85
|
+
grade = (
|
|
86
|
+
Grade()
|
|
87
|
+
.set_user_id(user_id)
|
|
88
|
+
.set_score_given(score_given)
|
|
89
|
+
.set_score_maximum(score_maximum)
|
|
90
|
+
.set_activity_progress(activity_progress)
|
|
91
|
+
.set_grading_progress(grading_progress)
|
|
92
|
+
.set_timestamp(timestamp or datetime.now(timezone.utc).isoformat())
|
|
93
|
+
)
|
|
94
|
+
if comment is not None:
|
|
95
|
+
grade.set_comment(comment)
|
|
96
|
+
return self.grades().put_grade(grade, lineitem)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Vendored LTI 1.3 Advantage engine.
|
|
2
|
+
|
|
3
|
+
This package is a vendored, rebranded copy of PyLTI1p3 (MIT licensed). It
|
|
4
|
+
provides the spec-correct, security-critical LTI 1.3 primitives: OIDC login,
|
|
5
|
+
JWT/JWKS validation, message launches, and the LTI Advantage services
|
|
6
|
+
(Assignment & Grade Services, Names & Role Provisioning, Deep Linking).
|
|
7
|
+
|
|
8
|
+
We vendor rather than depend on it so we own the code and can patch it; upstream
|
|
9
|
+
is unmaintained (last release 2022) but implements a frozen specification. See
|
|
10
|
+
``LICENSE`` at the repository root for the original MIT terms.
|
|
11
|
+
|
|
12
|
+
Do not import from here directly in application code — use the public
|
|
13
|
+
``ltitoolkit`` API and framework adapters instead. This subpackage is internal.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Upstream PyLTI1p3 release this vendored copy is based on.
|
|
17
|
+
VENDORED_FROM = "PyLTI1p3 2.0.0"
|
|
18
|
+
|
|
19
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import typing as t
|
|
2
|
+
import typing_extensions as te
|
|
3
|
+
from .exception import LtiException
|
|
4
|
+
from .lineitem import LineItem
|
|
5
|
+
from .grade import Grade
|
|
6
|
+
from .lineitem import TLineItem
|
|
7
|
+
from .service_connector import ServiceConnector, TServiceConnectorResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TAssignmentsGradersData = te.TypedDict(
|
|
11
|
+
"TAssignmentsGradersData",
|
|
12
|
+
{
|
|
13
|
+
"scope": t.List[
|
|
14
|
+
te.Literal[
|
|
15
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/score",
|
|
16
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly",
|
|
17
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
|
|
18
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly",
|
|
19
|
+
]
|
|
20
|
+
],
|
|
21
|
+
"lineitems": str,
|
|
22
|
+
"lineitem": str,
|
|
23
|
+
},
|
|
24
|
+
total=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AssignmentsGradesService:
|
|
29
|
+
_service_connector: ServiceConnector
|
|
30
|
+
_service_data: TAssignmentsGradersData
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, service_connector: ServiceConnector, service_data: TAssignmentsGradersData
|
|
34
|
+
):
|
|
35
|
+
self._service_connector = service_connector
|
|
36
|
+
self._service_data = service_data
|
|
37
|
+
|
|
38
|
+
def can_read_lineitem(self) -> bool:
|
|
39
|
+
return (
|
|
40
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"
|
|
41
|
+
in self._service_data["scope"]
|
|
42
|
+
or "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"
|
|
43
|
+
in self._service_data["scope"]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def can_create_lineitem(self) -> bool:
|
|
47
|
+
return (
|
|
48
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"
|
|
49
|
+
in self._service_data["scope"]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def can_read_grades(self) -> bool:
|
|
53
|
+
return (
|
|
54
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly"
|
|
55
|
+
in self._service_data["scope"]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def can_put_grade(self) -> bool:
|
|
59
|
+
return (
|
|
60
|
+
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
|
|
61
|
+
in self._service_data["scope"]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def put_grade(
|
|
65
|
+
self, grade: Grade, lineitem: t.Optional[LineItem] = None
|
|
66
|
+
) -> TServiceConnectorResponse:
|
|
67
|
+
"""
|
|
68
|
+
Send grade to the LTI platform.
|
|
69
|
+
|
|
70
|
+
:param grade: Grade instance
|
|
71
|
+
:param lineitem: LineItem instance
|
|
72
|
+
:return: dict with HTTP response body and headers
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
if not self.can_put_grade():
|
|
76
|
+
raise LtiException("Can't put grade: Missing required scope")
|
|
77
|
+
|
|
78
|
+
if lineitem:
|
|
79
|
+
if not lineitem.get_id():
|
|
80
|
+
lineitem = self.find_or_create_lineitem(lineitem)
|
|
81
|
+
score_url = lineitem.get_id()
|
|
82
|
+
elif not lineitem and self._service_data.get("lineitem"):
|
|
83
|
+
score_url = self._service_data.get("lineitem")
|
|
84
|
+
else:
|
|
85
|
+
raise LtiException("Can't find lineitem to put grade")
|
|
86
|
+
|
|
87
|
+
assert score_url is not None
|
|
88
|
+
score_url = self._add_url_path_ending(score_url, "scores")
|
|
89
|
+
return self._service_connector.make_service_request(
|
|
90
|
+
self._service_data["scope"],
|
|
91
|
+
score_url,
|
|
92
|
+
is_post=True,
|
|
93
|
+
data=grade.get_value(),
|
|
94
|
+
content_type="application/vnd.ims.lis.v1.score+json",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def get_lineitem(self, lineitem_url: t.Optional[str] = None):
|
|
98
|
+
"""
|
|
99
|
+
Retrieves an individual lineitem. By default retrieves the lineitem
|
|
100
|
+
associated with the LTI message.
|
|
101
|
+
|
|
102
|
+
:param lineitem_url: endpoint for LTI line item (optional)
|
|
103
|
+
:return: LineItem instance
|
|
104
|
+
"""
|
|
105
|
+
if not self.can_read_lineitem():
|
|
106
|
+
raise LtiException("Can't read lineitem: Missing required scope")
|
|
107
|
+
|
|
108
|
+
if lineitem_url is None:
|
|
109
|
+
lineitem_url = self._service_data["lineitem"]
|
|
110
|
+
|
|
111
|
+
lineitem_response = self._service_connector.make_service_request(
|
|
112
|
+
self._service_data["scope"],
|
|
113
|
+
lineitem_url,
|
|
114
|
+
accept="application/vnd.ims.lis.v2.lineitem+json",
|
|
115
|
+
)
|
|
116
|
+
return LineItem(t.cast(TLineItem, lineitem_response["body"]))
|
|
117
|
+
|
|
118
|
+
def get_lineitems_page(
|
|
119
|
+
self, lineitems_url: t.Optional[str] = None
|
|
120
|
+
) -> t.Tuple[list, t.Optional[str]]:
|
|
121
|
+
"""
|
|
122
|
+
Get one page with line items.
|
|
123
|
+
|
|
124
|
+
:param lineitems_url: LTI platform's URL (optional)
|
|
125
|
+
:return: tuple in format: (list with line items, next page url)
|
|
126
|
+
"""
|
|
127
|
+
if not self.can_read_lineitem():
|
|
128
|
+
raise LtiException("Can't read lineitem: Missing required scope")
|
|
129
|
+
|
|
130
|
+
if not lineitems_url:
|
|
131
|
+
lineitems_url = self._service_data["lineitems"]
|
|
132
|
+
|
|
133
|
+
lineitems = self._service_connector.make_service_request(
|
|
134
|
+
self._service_data["scope"],
|
|
135
|
+
lineitems_url,
|
|
136
|
+
accept="application/vnd.ims.lis.v2.lineitemcontainer+json",
|
|
137
|
+
)
|
|
138
|
+
if not isinstance(lineitems["body"], list):
|
|
139
|
+
raise LtiException("Unknown response type received for line items")
|
|
140
|
+
return lineitems["body"], lineitems["next_page_url"]
|
|
141
|
+
|
|
142
|
+
def get_lineitems(self) -> list:
|
|
143
|
+
"""
|
|
144
|
+
Get list of all available line items.
|
|
145
|
+
|
|
146
|
+
:return: list
|
|
147
|
+
"""
|
|
148
|
+
lineitems_res_lst = []
|
|
149
|
+
lineitems_url: t.Optional[str] = self._service_data["lineitems"]
|
|
150
|
+
|
|
151
|
+
while lineitems_url:
|
|
152
|
+
lineitems, lineitems_url = self.get_lineitems_page(lineitems_url)
|
|
153
|
+
lineitems_res_lst.extend(lineitems)
|
|
154
|
+
|
|
155
|
+
return lineitems_res_lst
|
|
156
|
+
|
|
157
|
+
def find_lineitem(self, prop_name: str, prop_value: t.Any) -> t.Optional[LineItem]:
|
|
158
|
+
"""
|
|
159
|
+
Find line item by some property (ID/Tag).
|
|
160
|
+
|
|
161
|
+
:param prop_name: property name
|
|
162
|
+
:param prop_value: property value
|
|
163
|
+
:return: LineItem instance or None
|
|
164
|
+
"""
|
|
165
|
+
lineitems_url: t.Optional[str] = self._service_data["lineitems"]
|
|
166
|
+
|
|
167
|
+
while lineitems_url:
|
|
168
|
+
lineitems, lineitems_url = self.get_lineitems_page(lineitems_url)
|
|
169
|
+
for lineitem in lineitems:
|
|
170
|
+
lineitem_prop_value = lineitem.get(prop_name)
|
|
171
|
+
if lineitem_prop_value == prop_value:
|
|
172
|
+
return LineItem(lineitem)
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def find_lineitem_by_id(self, ln_id: str) -> t.Optional[LineItem]:
|
|
176
|
+
"""
|
|
177
|
+
Find line item by ID.
|
|
178
|
+
|
|
179
|
+
:param ln_id: str
|
|
180
|
+
:return: LineItem instance or None
|
|
181
|
+
"""
|
|
182
|
+
return self.find_lineitem("id", ln_id)
|
|
183
|
+
|
|
184
|
+
def find_lineitem_by_tag(self, tag: str) -> t.Optional[LineItem]:
|
|
185
|
+
"""
|
|
186
|
+
Find line item by Tag.
|
|
187
|
+
|
|
188
|
+
:param tag: str
|
|
189
|
+
:return: LineItem instance or None
|
|
190
|
+
"""
|
|
191
|
+
return self.find_lineitem("tag", tag)
|
|
192
|
+
|
|
193
|
+
def find_lineitem_by_resource_link_id(
|
|
194
|
+
self, resource_link_id: str
|
|
195
|
+
) -> t.Optional[LineItem]:
|
|
196
|
+
"""
|
|
197
|
+
Find line item by Resource LinkID.
|
|
198
|
+
|
|
199
|
+
:param resource_link_id: str
|
|
200
|
+
:return: LineItem instance or None
|
|
201
|
+
"""
|
|
202
|
+
return self.find_lineitem("resourceLinkId", resource_link_id)
|
|
203
|
+
|
|
204
|
+
def find_lineitem_by_resource_id(self, resource_id: str) -> t.Optional[LineItem]:
|
|
205
|
+
"""
|
|
206
|
+
Find line item by Resource ID.
|
|
207
|
+
|
|
208
|
+
:param resource_id: str
|
|
209
|
+
:return: LineItem instance or None
|
|
210
|
+
"""
|
|
211
|
+
return self.find_lineitem("resourceId", resource_id)
|
|
212
|
+
|
|
213
|
+
def find_or_create_lineitem(
|
|
214
|
+
self, new_lineitem: LineItem, find_by: str = "tag"
|
|
215
|
+
) -> LineItem:
|
|
216
|
+
"""
|
|
217
|
+
Try to find line item using ID or Tag. New lime item will be created if nothing is found.
|
|
218
|
+
|
|
219
|
+
:param new_lineitem: LineItem instance
|
|
220
|
+
:param find_by: str ("tag"/"id")
|
|
221
|
+
:return: LineItem instance (based on response from the LTI platform)
|
|
222
|
+
"""
|
|
223
|
+
if find_by == "tag":
|
|
224
|
+
tag = new_lineitem.get_tag()
|
|
225
|
+
if not tag:
|
|
226
|
+
raise LtiException("Tag value is not specified")
|
|
227
|
+
lineitem = self.find_lineitem_by_tag(tag)
|
|
228
|
+
elif find_by == "id":
|
|
229
|
+
line_id = new_lineitem.get_id()
|
|
230
|
+
if not line_id:
|
|
231
|
+
raise LtiException("ID value is not specified")
|
|
232
|
+
lineitem = self.find_lineitem_by_id(line_id)
|
|
233
|
+
elif find_by == "resource_link_id":
|
|
234
|
+
resource_link_id = new_lineitem.get_resource_link_id()
|
|
235
|
+
if not resource_link_id:
|
|
236
|
+
raise LtiException("Resource Link ID value is not specified")
|
|
237
|
+
lineitem = self.find_lineitem_by_resource_link_id(resource_link_id)
|
|
238
|
+
elif find_by == "resource_id":
|
|
239
|
+
resource_id = new_lineitem.get_resource_id()
|
|
240
|
+
if not resource_id:
|
|
241
|
+
raise LtiException("Resource ID value is not specified")
|
|
242
|
+
lineitem = self.find_lineitem_by_resource_id(resource_id)
|
|
243
|
+
else:
|
|
244
|
+
raise LtiException('Invalid "find_by" value: ' + str(find_by))
|
|
245
|
+
|
|
246
|
+
if lineitem:
|
|
247
|
+
return lineitem
|
|
248
|
+
|
|
249
|
+
if not self.can_create_lineitem():
|
|
250
|
+
raise LtiException("Can't create lineitem: Missing required scope")
|
|
251
|
+
|
|
252
|
+
created_lineitem = self._service_connector.make_service_request(
|
|
253
|
+
self._service_data["scope"],
|
|
254
|
+
self._service_data["lineitems"],
|
|
255
|
+
is_post=True,
|
|
256
|
+
data=new_lineitem.get_value(),
|
|
257
|
+
content_type="application/vnd.ims.lis.v2.lineitem+json",
|
|
258
|
+
accept="application/vnd.ims.lis.v2.lineitem+json",
|
|
259
|
+
)
|
|
260
|
+
if not isinstance(created_lineitem["body"], dict):
|
|
261
|
+
raise LtiException("Unknown response type received for create line item")
|
|
262
|
+
return LineItem(t.cast(TLineItem, created_lineitem["body"]))
|
|
263
|
+
|
|
264
|
+
def get_grades(self, lineitem: t.Optional[LineItem] = None) -> list:
|
|
265
|
+
"""
|
|
266
|
+
Return all grades for the passed line item (across all users enrolled in the line item's context).
|
|
267
|
+
|
|
268
|
+
:param lineitem: LineItem instance
|
|
269
|
+
:return: list of grades
|
|
270
|
+
"""
|
|
271
|
+
if not self.can_read_grades():
|
|
272
|
+
raise LtiException("Can't read grades: Missing required scope")
|
|
273
|
+
|
|
274
|
+
if lineitem:
|
|
275
|
+
lineitem_id = lineitem.get_id()
|
|
276
|
+
else:
|
|
277
|
+
lineitem_id = self._service_data.get("lineitem")
|
|
278
|
+
|
|
279
|
+
if not lineitem_id:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
results_url = self._add_url_path_ending(lineitem_id, "results")
|
|
283
|
+
scores = self._service_connector.make_service_request(
|
|
284
|
+
self._service_data["scope"],
|
|
285
|
+
results_url,
|
|
286
|
+
accept="application/vnd.ims.lis.v2.resultcontainer+json",
|
|
287
|
+
)
|
|
288
|
+
if not isinstance(scores["body"], list):
|
|
289
|
+
raise LtiException("Unknown response type received for results")
|
|
290
|
+
return scores["body"]
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _add_url_path_ending(url: str, url_path_ending: str) -> str:
|
|
294
|
+
if "?" in url:
|
|
295
|
+
url_parts = url.split("?")
|
|
296
|
+
new_url = url_parts[0]
|
|
297
|
+
new_url += "" if new_url.endswith("/") else "/"
|
|
298
|
+
return new_url + url_path_ending + "?" + url_parts[1]
|
|
299
|
+
url += "" if url.endswith("/") else "/"
|
|
300
|
+
return url + url_path_ending
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import http.cookies as Cookie # type: ignore
|
|
2
|
+
import django # type: ignore
|
|
3
|
+
from ltitoolkit.core.cookie import CookieService
|
|
4
|
+
|
|
5
|
+
# Add support for the SameSite attribute (obsolete when PY37 is unsupported).
|
|
6
|
+
# pylint: disable=protected-access
|
|
7
|
+
if "samesite" not in Cookie.Morsel._reserved: # type: ignore
|
|
8
|
+
Cookie.Morsel._reserved.setdefault("samesite", "SameSite") # type: ignore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DjangoCookieService(CookieService):
|
|
12
|
+
_request = None
|
|
13
|
+
_cookie_data_to_set = None
|
|
14
|
+
|
|
15
|
+
def __init__(self, request):
|
|
16
|
+
self._request = request
|
|
17
|
+
self._cookie_data_to_set = {}
|
|
18
|
+
|
|
19
|
+
def _get_key(self, key):
|
|
20
|
+
return self._cookie_prefix + "-" + key
|
|
21
|
+
|
|
22
|
+
def get_cookie(self, name):
|
|
23
|
+
return self._request.get_cookie(self._get_key(name))
|
|
24
|
+
|
|
25
|
+
def set_cookie(self, name, value, exp=3600):
|
|
26
|
+
self._cookie_data_to_set[self._get_key(name)] = {
|
|
27
|
+
"value": value,
|
|
28
|
+
"exp": exp,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def update_response(self, response):
|
|
32
|
+
for key, cookie_data in self._cookie_data_to_set.items():
|
|
33
|
+
kwargs = {
|
|
34
|
+
"value": cookie_data["value"],
|
|
35
|
+
"max_age": cookie_data["exp"],
|
|
36
|
+
"secure": self._request.is_secure(),
|
|
37
|
+
"httponly": True,
|
|
38
|
+
"path": "/",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if self._request.is_secure():
|
|
42
|
+
# samesite argument was added in Django 2.1, but samesite could be set as None only from Django 3.1
|
|
43
|
+
# https://github.com/django/django/pull/11894
|
|
44
|
+
django_support_samesite_none = django.VERSION[0] > 3 or (
|
|
45
|
+
django.VERSION[0] == 3 and django.VERSION[1] >= 1
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# SameSite=None and Secure=True are required to work inside iframes
|
|
49
|
+
if django_support_samesite_none:
|
|
50
|
+
kwargs["samesite"] = "None"
|
|
51
|
+
response.set_cookie(key, **kwargs)
|
|
52
|
+
else:
|
|
53
|
+
response.set_cookie(key, **kwargs)
|
|
54
|
+
response.cookies[key]["samesite"] = "None"
|
|
55
|
+
else:
|
|
56
|
+
response.set_cookie(key, **kwargs)
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django.core.cache import caches # type: ignore
|
|
2
|
+
from ltitoolkit.core.launch_data_storage.cache import CacheDataStorage
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DjangoCacheDataStorage(CacheDataStorage):
|
|
6
|
+
_cache = None
|
|
7
|
+
|
|
8
|
+
def __init__(self, cache_name="default", **kwargs):
|
|
9
|
+
self._cache = caches[cache_name]
|
|
10
|
+
super().__init__(cache_name, **kwargs)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from ltitoolkit.core.deployment import Deployment
|
|
4
|
+
from ltitoolkit.core.exception import LtiException
|
|
5
|
+
from ltitoolkit.core.registration import Registration
|
|
6
|
+
from ltitoolkit.core.tool_config.abstract import ToolConfAbstract
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
default_app_config = (
|
|
10
|
+
"ltitoolkit.core.contrib.django.lti1p3_tool_config.apps.PyLTI1p3ToolConfig"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DjangoDbToolConf(ToolConfAbstract):
|
|
15
|
+
_lti_tools = None
|
|
16
|
+
_tools_cls = None
|
|
17
|
+
_keys_cls = None
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
# pylint: disable=import-outside-toplevel
|
|
21
|
+
from .models import LtiTool, LtiToolKey
|
|
22
|
+
|
|
23
|
+
super().__init__()
|
|
24
|
+
self._lti_tools = {}
|
|
25
|
+
self._tools_cls = LtiTool
|
|
26
|
+
self._keys_cls = LtiToolKey
|
|
27
|
+
|
|
28
|
+
def get_lti_tool(self, iss, client_id):
|
|
29
|
+
# pylint: disable=no-member
|
|
30
|
+
lti_tool = (
|
|
31
|
+
self._lti_tools.get(iss)
|
|
32
|
+
if client_id is None
|
|
33
|
+
else self._lti_tools.get(iss, {}).get(client_id)
|
|
34
|
+
)
|
|
35
|
+
if lti_tool:
|
|
36
|
+
return lti_tool
|
|
37
|
+
|
|
38
|
+
if client_id is None:
|
|
39
|
+
lti_tool = (
|
|
40
|
+
self._tools_cls.objects.filter(issuer=iss, is_active=True)
|
|
41
|
+
.order_by("use_by_default")
|
|
42
|
+
.first()
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
try:
|
|
46
|
+
lti_tool = self._tools_cls.objects.get(
|
|
47
|
+
issuer=iss, client_id=client_id, is_active=True
|
|
48
|
+
)
|
|
49
|
+
except self._tools_cls.DoesNotExist:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
if lti_tool is None:
|
|
53
|
+
raise LtiException(
|
|
54
|
+
f"iss {iss} [client_id={client_id}] not found in settings"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if client_id is None:
|
|
58
|
+
self._lti_tools[iss] = lti_tool
|
|
59
|
+
else:
|
|
60
|
+
if iss not in self._lti_tools:
|
|
61
|
+
self._lti_tools[iss] = {}
|
|
62
|
+
self._lti_tools[iss][client_id] = lti_tool
|
|
63
|
+
|
|
64
|
+
return lti_tool
|
|
65
|
+
|
|
66
|
+
def check_iss_has_one_client(self, iss):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def check_iss_has_many_clients(self, iss):
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def find_registration_by_issuer(self, iss, *args, **kwargs):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def find_registration_by_params(self, iss, client_id, *args, **kwargs):
|
|
76
|
+
lti_tool = self.get_lti_tool(iss, client_id)
|
|
77
|
+
auth_audience = lti_tool.auth_audience if lti_tool.auth_audience else None
|
|
78
|
+
key_set = json.loads(lti_tool.key_set) if lti_tool.key_set else None
|
|
79
|
+
key_set_url = lti_tool.key_set_url if lti_tool.key_set_url else None
|
|
80
|
+
tool_public_key = (
|
|
81
|
+
lti_tool.tool_key.public_key if lti_tool.tool_key.public_key else None
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
reg = Registration()
|
|
85
|
+
reg.set_auth_login_url(lti_tool.auth_login_url).set_auth_token_url(
|
|
86
|
+
lti_tool.auth_token_url
|
|
87
|
+
).set_auth_audience(auth_audience).set_client_id(
|
|
88
|
+
lti_tool.client_id
|
|
89
|
+
).set_key_set(
|
|
90
|
+
key_set
|
|
91
|
+
).set_key_set_url(
|
|
92
|
+
key_set_url
|
|
93
|
+
).set_issuer(
|
|
94
|
+
lti_tool.issuer
|
|
95
|
+
).set_tool_private_key(
|
|
96
|
+
lti_tool.tool_key.private_key
|
|
97
|
+
).set_tool_public_key(
|
|
98
|
+
tool_public_key
|
|
99
|
+
)
|
|
100
|
+
return reg
|
|
101
|
+
|
|
102
|
+
def find_deployment(self, iss, deployment_id):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
def find_deployment_by_params(self, iss, deployment_id, client_id, *args, **kwargs):
|
|
106
|
+
lti_tool = self.get_lti_tool(iss, client_id)
|
|
107
|
+
deployment_ids = (
|
|
108
|
+
json.loads(lti_tool.deployment_ids) if lti_tool.deployment_ids else []
|
|
109
|
+
)
|
|
110
|
+
if deployment_id not in deployment_ids:
|
|
111
|
+
return None
|
|
112
|
+
d = Deployment()
|
|
113
|
+
return d.set_deployment_id(deployment_id)
|
|
114
|
+
|
|
115
|
+
def get_jwks(self, iss=None, client_id=None, **kwargs):
|
|
116
|
+
# pylint: disable=no-member
|
|
117
|
+
search_kwargs = {}
|
|
118
|
+
if iss:
|
|
119
|
+
search_kwargs["lti_tools__issuer"] = iss
|
|
120
|
+
if client_id:
|
|
121
|
+
search_kwargs["lti_tools__client_id"] = client_id
|
|
122
|
+
|
|
123
|
+
if search_kwargs:
|
|
124
|
+
search_kwargs["lti_tools__is_active"] = True
|
|
125
|
+
qs = self._keys_cls.objects.filter(**search_kwargs)
|
|
126
|
+
else:
|
|
127
|
+
qs = self._keys_cls.objects.all()
|
|
128
|
+
|
|
129
|
+
jwks = []
|
|
130
|
+
public_key_lst = []
|
|
131
|
+
|
|
132
|
+
for key in qs:
|
|
133
|
+
if key.public_key and key.public_key not in public_key_lst:
|
|
134
|
+
if key.public_jwk:
|
|
135
|
+
jwks.append(json.loads(key.public_jwk))
|
|
136
|
+
else:
|
|
137
|
+
jwks.append(Registration.get_jwk(key.public_key))
|
|
138
|
+
public_key_lst.append(key.public_key)
|
|
139
|
+
return {"keys": jwks}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
from django.contrib import admin
|
|
3
|
+
|
|
4
|
+
from .models import LtiTool, LtiToolKey
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LtiToolKeyAdmin(admin.ModelAdmin):
|
|
8
|
+
"""Admin for LTI Tool Key"""
|
|
9
|
+
|
|
10
|
+
list_display = ("id", "name")
|
|
11
|
+
|
|
12
|
+
add_fieldsets = ((None, {"fields": ("name", "private_key", "public_key")}),)
|
|
13
|
+
|
|
14
|
+
change_fieldsets = (
|
|
15
|
+
(None, {"fields": ("name", "private_key", "public_key", "public_jwk")}),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
readonly_fields = ("public_jwk",)
|
|
19
|
+
|
|
20
|
+
def get_form(self, request, obj=None, **kwargs): # pylint: disable=arguments-differ
|
|
21
|
+
help_texts = {
|
|
22
|
+
"public_key_jwk_json": "Tool's generated Public key presented as JWK."
|
|
23
|
+
}
|
|
24
|
+
kwargs.update({"help_texts": help_texts})
|
|
25
|
+
return super().get_form(request, obj, **kwargs)
|
|
26
|
+
|
|
27
|
+
def get_fieldsets(self, request, obj=None): # pylint: disable=unused-argument
|
|
28
|
+
if not obj:
|
|
29
|
+
return self.add_fieldsets
|
|
30
|
+
return self.change_fieldsets
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LtiToolAdmin(admin.ModelAdmin):
|
|
34
|
+
"""Admin for LTI Tool"""
|
|
35
|
+
|
|
36
|
+
search_fields = (
|
|
37
|
+
"title",
|
|
38
|
+
"issuer",
|
|
39
|
+
"client_id",
|
|
40
|
+
"auth_login_url",
|
|
41
|
+
"auth_token_url",
|
|
42
|
+
"key_set_url",
|
|
43
|
+
)
|
|
44
|
+
list_display = ("id", "title", "is_active", "issuer", "client_id", "deployment_ids")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
admin.site.register(LtiToolKey, LtiToolKeyAdmin)
|
|
48
|
+
admin.site.register(LtiTool, LtiToolAdmin)
|