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.
Files changed (94) hide show
  1. ff_ltitoolkit-0.1.0.dist-info/METADATA +98 -0
  2. ff_ltitoolkit-0.1.0.dist-info/RECORD +94 -0
  3. ff_ltitoolkit-0.1.0.dist-info/WHEEL +4 -0
  4. ff_ltitoolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  5. ltitoolkit/__init__.py +20 -0
  6. ltitoolkit/adapters/__init__.py +11 -0
  7. ltitoolkit/adapters/brightspace/__init__.py +35 -0
  8. ltitoolkit/adapters/brightspace/client.py +176 -0
  9. ltitoolkit/adapters/canvas/__init__.py +27 -0
  10. ltitoolkit/adapters/canvas/client.py +142 -0
  11. ltitoolkit/advantage/__init__.py +9 -0
  12. ltitoolkit/advantage/service.py +96 -0
  13. ltitoolkit/core/__init__.py +19 -0
  14. ltitoolkit/core/actions.py +6 -0
  15. ltitoolkit/core/assignments_grades.py +300 -0
  16. ltitoolkit/core/contrib/__init__.py +0 -0
  17. ltitoolkit/core/contrib/django/__init__.py +5 -0
  18. ltitoolkit/core/contrib/django/cookie.py +56 -0
  19. ltitoolkit/core/contrib/django/launch_data_storage/__init__.py +0 -0
  20. ltitoolkit/core/contrib/django/launch_data_storage/cache.py +10 -0
  21. ltitoolkit/core/contrib/django/lti1p3_tool_config/__init__.py +139 -0
  22. ltitoolkit/core/contrib/django/lti1p3_tool_config/admin.py +48 -0
  23. ltitoolkit/core/contrib/django/lti1p3_tool_config/apps.py +6 -0
  24. ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/0001_initial.py +168 -0
  25. ltitoolkit/core/contrib/django/lti1p3_tool_config/migrations/__init__.py +0 -0
  26. ltitoolkit/core/contrib/django/lti1p3_tool_config/models.py +185 -0
  27. ltitoolkit/core/contrib/django/message_launch.py +39 -0
  28. ltitoolkit/core/contrib/django/oidc_login.py +41 -0
  29. ltitoolkit/core/contrib/django/redirect.py +34 -0
  30. ltitoolkit/core/contrib/django/request.py +32 -0
  31. ltitoolkit/core/contrib/django/session.py +5 -0
  32. ltitoolkit/core/contrib/flask/__init__.py +7 -0
  33. ltitoolkit/core/contrib/flask/cookie.py +34 -0
  34. ltitoolkit/core/contrib/flask/launch_data_storage/__init__.py +0 -0
  35. ltitoolkit/core/contrib/flask/launch_data_storage/cache.py +9 -0
  36. ltitoolkit/core/contrib/flask/message_launch.py +32 -0
  37. ltitoolkit/core/contrib/flask/oidc_login.py +31 -0
  38. ltitoolkit/core/contrib/flask/redirect.py +34 -0
  39. ltitoolkit/core/contrib/flask/request.py +40 -0
  40. ltitoolkit/core/contrib/flask/session.py +5 -0
  41. ltitoolkit/core/contrib/py.typed +0 -0
  42. ltitoolkit/core/cookie.py +17 -0
  43. ltitoolkit/core/cookies_allowed_check.py +151 -0
  44. ltitoolkit/core/course_groups.py +115 -0
  45. ltitoolkit/core/deep_link.py +100 -0
  46. ltitoolkit/core/deep_link_resource.py +96 -0
  47. ltitoolkit/core/deployment.py +13 -0
  48. ltitoolkit/core/exception.py +16 -0
  49. ltitoolkit/core/grade.py +143 -0
  50. ltitoolkit/core/launch_data_storage/__init__.py +0 -0
  51. ltitoolkit/core/launch_data_storage/base.py +75 -0
  52. ltitoolkit/core/launch_data_storage/cache.py +43 -0
  53. ltitoolkit/core/launch_data_storage/session.py +29 -0
  54. ltitoolkit/core/lineitem.py +205 -0
  55. ltitoolkit/core/message_launch.py +828 -0
  56. ltitoolkit/core/message_validators/__init__.py +13 -0
  57. ltitoolkit/core/message_validators/abstract.py +25 -0
  58. ltitoolkit/core/message_validators/deep_link.py +34 -0
  59. ltitoolkit/core/message_validators/privacy_launch.py +40 -0
  60. ltitoolkit/core/message_validators/resource_message.py +21 -0
  61. ltitoolkit/core/message_validators/submission_review.py +45 -0
  62. ltitoolkit/core/names_roles.py +97 -0
  63. ltitoolkit/core/oidc_login.py +275 -0
  64. ltitoolkit/core/py.typed +0 -0
  65. ltitoolkit/core/redirect.py +24 -0
  66. ltitoolkit/core/registration.py +119 -0
  67. ltitoolkit/core/request.py +17 -0
  68. ltitoolkit/core/roles.py +109 -0
  69. ltitoolkit/core/service_connector.py +144 -0
  70. ltitoolkit/core/session.py +70 -0
  71. ltitoolkit/core/tool_config/__init__.py +4 -0
  72. ltitoolkit/core/tool_config/abstract.py +117 -0
  73. ltitoolkit/core/tool_config/dict.py +253 -0
  74. ltitoolkit/core/tool_config/json_file.py +100 -0
  75. ltitoolkit/core/tool_config/py.typed +0 -0
  76. ltitoolkit/core/utils.py +10 -0
  77. ltitoolkit/dynamic_registration/__init__.py +39 -0
  78. ltitoolkit/dynamic_registration/models.py +192 -0
  79. ltitoolkit/dynamic_registration/service.py +156 -0
  80. ltitoolkit/dynamic_registration/store.py +40 -0
  81. ltitoolkit/dynamic_registration/tool_conf.py +102 -0
  82. ltitoolkit/exceptions.py +42 -0
  83. ltitoolkit/fastapi/__init__.py +30 -0
  84. ltitoolkit/fastapi/cookie.py +53 -0
  85. ltitoolkit/fastapi/dynamic_registration.py +40 -0
  86. ltitoolkit/fastapi/message_launch.py +60 -0
  87. ltitoolkit/fastapi/oidc_login.py +47 -0
  88. ltitoolkit/fastapi/redirect.py +54 -0
  89. ltitoolkit/fastapi/request.py +77 -0
  90. ltitoolkit/fastapi/session.py +13 -0
  91. ltitoolkit/http.py +80 -0
  92. ltitoolkit/token/__init__.py +20 -0
  93. ltitoolkit/token/cache.py +47 -0
  94. 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,6 @@
1
+ import typing_extensions as te
2
+
3
+
4
+ class Action:
5
+ OIDC_LOGIN: te.Final = "oidc_login"
6
+ MESSAGE_LAUNCH: te.Final = "message_launch"
@@ -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,5 @@
1
+ # flake8: noqa
2
+ from .message_launch import DjangoMessageLaunch
3
+ from .oidc_login import DjangoOIDCLogin
4
+ from .launch_data_storage.cache import DjangoCacheDataStorage
5
+ from .lti1p3_tool_config import DjangoDbToolConf
@@ -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)
@@ -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)