Skip to content

Commit

Permalink
feat: Get DeepLinkingForm content_items choices from provider function
Browse files Browse the repository at this point in the history
refactor: DeepLinkingForm
  • Loading branch information
kuipumu committed Jul 10, 2024
1 parent 0733d3e commit 6fbbbc7
Show file tree
Hide file tree
Showing 9 changed files with 645 additions and 297 deletions.
212 changes: 148 additions & 64 deletions openedx_lti_tool_plugin/deep_linking/forms.py
Original file line number Diff line number Diff line change
@@ -1,131 +1,215 @@
"""Django Forms."""
import json
from typing import Optional, Set, Tuple
import logging
from importlib import import_module
from typing import List, Optional, Tuple

from django import forms
from django.conf import settings
from django.http.request import HttpRequest
from django.urls import reverse
from django.utils.translation import gettext as _
from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool
from jsonschema import validate
from pylti1p3.deep_link_resource import DeepLinkResource

from openedx_lti_tool_plugin.apps import OpenEdxLtiToolPluginConfig as app_config
from openedx_lti_tool_plugin.deep_linking.exceptions import DeepLinkingException
from openedx_lti_tool_plugin.edxapp_wrapper.learning_sequences import course_context
from openedx_lti_tool_plugin.models import CourseAccessConfiguration
from openedx_lti_tool_plugin.waffle import COURSE_ACCESS_CONFIGURATION
from openedx_lti_tool_plugin.edxapp_wrapper.site_configuration_module import configuration_helpers
from openedx_lti_tool_plugin.models import CourseContext
from openedx_lti_tool_plugin.utils import get_identity_claims

log = logging.getLogger(__name__)


class DeepLinkingForm(forms.Form):
"""Deep Linking Form."""

CONTENT_ITEMS_SCHEMA = {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'url': {'type': 'string'},
'title': {'type': 'string'},
},
'additionalProperties': True,
},
}

content_items = forms.MultipleChoiceField(
required=False,
widget=forms.CheckboxSelectMultiple,
label=_('Courses'),
label=_('Content Items'),
)

def __init__(
self,
*args: tuple,
request: HttpRequest,
lti_tool: LtiTool,
launch_data: dict,
**kwargs: dict,
):
"""Class __init__ method.
Initialize class instance attributes and add field choices
to the `content_items` field.
Initialize class instance attributes and set the choices
of the content_items field.
Args:
*args: Variable length argument list.
request: HttpRequest object.
lti_tool: LtiTool model instance.
launch_data: Launch message data.
**kwargs: Arbitrary keyword arguments.
"""
super().__init__(*args, **kwargs)
self.request = request
self.lti_tool = lti_tool
self.launch_data = launch_data
self.fields['content_items'].choices = self.get_content_items_choices()

def get_content_items_choices(self) -> Set[Optional[Tuple[str, str]]]:
"""Get `content_items` field choices.
def get_content_items_choices(self) -> List[Optional[Tuple[str, str]]]:
"""Get content_items field choices.
This method will get the content_items field choices from a list
of content items dictionaries provided by the get_content_items method or
the get_content_items_from_provider method if a content items provider is setup.
A content item is a JSON that represents a content the LTI Platform can consume,
this could be an LTI resource link launch URL, an URL to a resource hosted
on the internet, an HTML fragment, or any other kind of content type defined
by the `type` JSON attribute.
Each choice that this method returns is a JSON string representing a content item.
Returns:
Set of tuples with choices for the `content_items` field or an empty set.
A list of tuples with content_items field choices or empty list.
.. _LTI Deep Linking Specification - Content Item Types:
https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types
"""
return {
self.get_content_items_choice(course)
for course in self.get_course_contexts()
}
return [
(json.dumps(content_item), content_item.get('title', ''))
for content_item in (
self.get_content_items_from_provider()
or self.get_content_items()
)
]

def get_content_items_choice(self, course) -> Tuple[str, str]:
"""Get `content_items` field choice.
def get_content_items_from_provider(self) -> List[Optional[dict]]:
"""Get content items from a provider function.
Args:
course (CourseContext): Course object.
This method will try to obtain content items from a provider function.
To setup a provider function the OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER setting
must be set to a string with the full path to the function that will act has a provider:
Example:
OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER = 'example.module.path.provider_function'
This method will then try to import and call the function, the call will include
the HTTPRequest object and deep linking launch data dictionary received from
the deep linking request has arguments.
The content items returned from the function must be a list of dictionaries,
this list will be validated with a JSON Schema validator using a schema defined
in the CONTENT_ITEMS_SCHEMA constant.
Returns:
Tuple containing the choice value and name.
A list with content item dictionaries.
An empty list if OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER setting is None.
or there was an Exception importing or calling the provider function,
or the data returned by the provider function is not valid.
or the provider function returned an empty list.
.. _LTI Deep Linking Specification - LTI Resource Link:
https://www.imsglobal.org/spec/lti-dl/v2p0#lti-resource-link
.. _LTI Deep Linking Specification - Content Item Types:
https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types
"""
relative_url = reverse(
f'{app_config.name}:1.3:resource-link:launch-course',
kwargs={'course_id': course.learning_context.context_key},
)
if not (setting := configuration_helpers().get_value(
'OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER',
settings.OLTITP_DEEP_LINKING_CONTENT_ITEMS_PROVIDER,
)):
return []

return (
self.request.build_absolute_uri(relative_url),
course.learning_context.title,
)
try:
path, name = str(setting).rsplit('.', 1)
content_items = getattr(import_module(path), name)(
self.request,
self.launch_data,
)
validate(content_items, self.CONTENT_ITEMS_SCHEMA)

def get_course_contexts(self):
"""Get CourseContext objects.
return content_items

Returns:self.cleaned_data
All CourseContext objects if COURSE_ACCESS_CONFIGURATION switch
is disabled or all CourseContext objects matching the IDs in
the CourseAccessConfiguration `allowed_course_ids` field.
except Exception as exc: # pylint: disable=broad-exception-caught
log_extra = {
'setting': setting,
'exception': str(exc),
}
log.error(f'Error obtaining content items from provider: {log_extra}')

Raises:
CourseAccessConfiguration.DoesNotExist: If CourseAccessConfiguration
does not exist for this form `lti_tool` attribute.
return []

def get_content_items(self) -> List[Optional[dict]]:
"""Get content items.
Returns:
A list of content item dictionaries or an empty list.
.. _LTI Deep Linking Specification - Content Item Types:
https://www.imsglobal.org/spec/lti-dl/v2p0#content-item-types
"""
if not COURSE_ACCESS_CONFIGURATION.is_enabled():
return course_context().objects.all()
iss, aud, _sub, _pii = get_identity_claims(self.launch_data)

try:
course_access_config = CourseAccessConfiguration.objects.get(
lti_tool=self.lti_tool,
return [
{
'url': self.build_content_item_url(course),
'title': course.title,
}
for course in CourseContext.objects.all_for_lti_tool(iss, aud)
]

def build_content_item_url(self, course: CourseContext) -> str:
"""Build content item URL.
Args:
course: CourseContext object.
Returns:
An absolute LTI 1.3 resource link launch URL.
"""
return self.request.build_absolute_uri(
reverse(
f'{app_config.name}:1.3:resource-link:launch-course',
kwargs={'course_id': course.course_id},
)
except CourseAccessConfiguration.DoesNotExist as exc:
raise DeepLinkingException(
_(f'Course access configuration not found: {self.lti_tool.title}.'),
) from exc

return course_context().objects.filter(
learning_context__context_key__in=json.loads(
course_access_config.allowed_course_ids,
),
)

def get_deep_link_resources(self) -> Set[Optional[DeepLinkResource]]:
"""Get DeepLinkResource objects from this form `cleaned_data` attribute.
def clean(self) -> dict:
"""Form clean.
This method will transform all the JSON strings from the cleaned content_items data
into a list of DeepLinkResource objects that will be added to the cleaned data
dictionary deep_link_resources key.
Returns:
Set of DeepLinkResource objects or an empty set
A dictionary with cleaned form data.
.. _LTI 1.3 Advantage Tool implementation in Python - LTI Message Launches:
https://github.com/dmitry-viskov/pylti1.3?tab=readme-ov-file#deep-linking-responses
"""
return {
DeepLinkResource().set_url(content_item)
for content_item in self.cleaned_data.get('content_items', [])
}
super().clean()
deep_link_resources = []

for content_item in self.cleaned_data.get('content_items', []):
content_item = json.loads(content_item)
deep_link_resource = DeepLinkResource()
deep_link_resource.set_title(content_item.get('title'))
deep_link_resource.set_url(content_item.get('url'))
deep_link_resources.append(deep_link_resource)

self.cleaned_data['deep_link_resources'] = deep_link_resources

return self.cleaned_data
Loading

0 comments on commit 6fbbbc7

Please sign in to comment.