Repository: apluslms/a-plus

Issue:
Make hidden courses accessible through LTI
Sometimes, when A+ content is intended to be accessed only through external LTI 1.3 platform, it is not desirable to let students access it through A+ front page, nor enroll to it from A+, for example to avoid confusion in transferring points to LTI platform. The most straightforward way to do this is to hide the course in A+ settings, but override the hidden property when content is accessed through LTI.


Discussion:

Resolving patch:
diff --git a/course/permissions.py b/course/permissions.py
--- a/course/permissions.py
+++ b/course/permissions.py
@@ -22,6 +22,7 @@
     CourseModule,
     CourseInstance,
 )
+from lti_tool.utils import has_lti_access_to_course
 
 
 class JWTObjectPermission(Permission):
@@ -137,7 +138,8 @@ def is_object_visible(self, request, view, course): # pylint: disable=arguments-
             return True
 
         # Course is not visible if it's hidden
-        if not course.visible_to_students:
+        # If course is launched through LTI, it is shown even if marked as hidden in A+ UI.
+        if not course.visible_to_students and not has_lti_access_to_course(request, view, course):
             self.error_msg(_('COURSE_VISIBILITY_ERROR_NOT_VISIBLE'))
             return False
 
diff --git a/lti_tool/utils.py b/lti_tool/utils.py
--- a/lti_tool/utils.py
+++ b/lti_tool/utils.py
@@ -1,12 +1,18 @@
 import logging
+from typing import Any, Optional, Tuple
 
 from django.conf import settings
 from django.contrib.auth.models import User
+from django.http.request import HttpRequest
 from pylti1p3.grade import Grade
 from pylti1p3.contrib.django import DjangoMessageLaunch, DjangoCacheDataStorage, DjangoDbToolConf
 from pylti1p3.lineitem import LineItem
+from pylti1p3.message_launch import TLaunchData
 from pylti1p3.exception import LtiException, LtiServiceException
 
+from course.models import CourseInstance
+
+
 logger = logging.getLogger('aplus.lti_tool')
 
 def get_launch_url(request):
@@ -25,6 +31,41 @@ def get_tool_conf():
 def get_launch_data_storage():
     return DjangoCacheDataStorage()
 
+def parse_lti_session_params(
+        request: HttpRequest,
+        ) -> Tuple[Optional[DjangoMessageLaunch], Optional[TLaunchData]]:
+    launch_id = request.session.get("lti-launch-id", None)
+    if not launch_id:
+        return None, None
+    tool_conf = get_tool_conf()
+    message_launch = DjangoMessageLaunch.from_cache(
+        launch_id,
+        request,
+        tool_conf,
+        launch_data_storage=get_launch_data_storage(),
+    )
+    message_launch_data = message_launch.get_launch_data()
+    return message_launch, message_launch_data
+
+def has_lti_access_to_course(
+        request: HttpRequest,
+        view: Any,
+        target_course_instance: CourseInstance,
+        ) -> bool:
+    lti_scope = getattr(view, 'lti_scope', None)
+    if not lti_scope:
+        _message_launch, message_launch_data = parse_lti_session_params(request)
+        if message_launch_data:
+            lti_scope = message_launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom")
+            if not lti_scope:
+                return False
+        else:
+            return False
+    return (
+        lti_scope.get('course_slug') == target_course_instance.course.url
+        and lti_scope.get('instance_slug') == target_course_instance.url
+    )
+
 def send_lti_points(request, submission):
     from exercise.exercise_summary import UserExerciseSummary # pylint: disable=import-outside-toplevel
     exercise = submission.exercise
diff --git a/lti_tool/views.py b/lti_tool/views.py
--- a/lti_tool/views.py
+++ b/lti_tool/views.py
@@ -8,6 +8,7 @@
 from django.contrib.auth import login
 from django.urls import reverse
 from django.utils.decorators import method_decorator
+from django.utils.translation import gettext_lazy as _
 from pylti1p3.contrib.django import DjangoOIDCLogin, DjangoMessageLaunch
 from pylti1p3.deep_link_resource import DeepLinkResource
 from pylti1p3.lineitem import LineItem
@@ -22,7 +23,12 @@
 from lib.localization_syntax import pick_localized
 from lib.viewbase import BaseTemplateView, BaseRedirectView, BaseMixin
 from authorization.permissions import ACCESS
-from .utils import get_tool_conf, get_launch_data_storage, get_launch_url
+from .utils import (
+    get_tool_conf,
+    get_launch_data_storage,
+    get_launch_url,
+    parse_lti_session_params,
+)
 
 
 logger = logging.getLogger('aplus.lti_tool')
@@ -119,17 +125,46 @@ class LtiSessionMixin(BaseMixin):
     def dispatch(self, request, *args, **kwargs):
         return super().dispatch(request, *args, **kwargs)
 
-    def parse_lti_session_params(self):
-        launch_id = self.request.session.get("lti-launch-id", None)
-        tool_conf = get_tool_conf()
-        if launch_id:
-            self.message_launch = DjangoMessageLaunch.from_cache(
-                launch_id,
-                self.request,
-                tool_conf,
-                launch_data_storage=get_launch_data_storage()
-            )
-            self.message_launch_data = self.message_launch.get_launch_data()
+    def adjust_visibility(self, content):
+        """
+        Check whether given navigation link in ContentCache should be shown in LTI view.
+        This depends on whether LTI link points to single exercise, or whole module/course.
+        """
+        # LTI link points to specific exercise: no navigation allowed
+        if 'exercise_path' in self.lti_scope:
+            return None
+        if not content:
+            return None
+
+        try:
+            if content['type'] == 'exercise':
+                mod = CourseModule.objects.get(pk=content['module_id'])
+                lo = LearningObject.objects.get(pk=content['id'])
+                content.update({'link': lo.get_url("lti-exercise")})
+            elif content['type'] == 'module':
+                mod = CourseModule.objects.get(pk=content['id'])
+                content.update({'link': mod.get_url("lti-module")})
+            else:
+                return None
+        except (CourseModule.DoesNotExist, LearningObject.DoesNotExist):
+            return None
+
+        # If LTI link points to specific module: check that the target URL is in same module
+        if 'module_slug' in self.lti_scope and mod.url != self.lti_scope['module_slug']:
+            return None
+        return content
+
+    def get_upper(self, content):
+        if content['type'] == 'exercise':
+            mod = CourseModule.objects.get(pk=content['module_id'])
+            upper, _tree, _prev, _nex = self.content.find(mod)
+            return self.adjust_visibility(upper)
+        return None
+
+    def get_resource_objects(self):
+        super().get_resource_objects()
+        self.message_launch, self.message_launch_data = parse_lti_session_params(self.request)
+        self.lti_scope = self.message_launch_data.get("https://purl.imsglobal.org/spec/lti/claim/custom")
 
     def get_common_objects(self):
         super().get_common_objects()
@@ -146,11 +181,16 @@ class LtiInstanceView(LtiSessionMixin, InstanceView):
     access_mode = ACCESS.ENROLLED
     template_name = "lti_tool/lti_course.html"
 
+    def get_common_objects(self) -> None:
+        super().get_common_objects()
+        self.modules = self.points.modules_flatted()
+        self.note("modules")
+
     def get(self, request, *args, **kwargs):
         # Edit links to point to LTI views
         module_model_objs = CourseModule.objects.filter(course_instance=self.instance)
         learningobjects = LearningObject.objects.filter(course_module__course_instance=self.instance)
-        for module in self.content.data['modules']:
+        for module in self.modules:
             module_model_obj = next((x for x in module_model_objs if x.id == module['id']), None)
             module.update({'link': module_model_obj.get_url("lti-module")})
             for exercise in module['children']:
@@ -164,11 +204,20 @@ class LtiModuleView(LtiSessionMixin, ModuleView):
     access_mode = ACCESS.ENROLLED
     template_name = "lti_tool/lti_module.html"
 
+    def get_common_objects(self) -> None:
+        super().get_common_objects()
+        if 'module_slug' not in self.lti_scope:
+            self.upper_name = _("COURSE_MATERIALS")  # Course-level LTI association can access whole instance
+            self.upper_link = self.instance.get_url("lti-course")
+            self.note('upper_name', 'upper_link')
+        self.previous = self.adjust_visibility(self.previous)
+        self.next = self.adjust_visibility(self.next)
+
     def get(self, request, *args, **kwargs):
         learningobjects = LearningObject.objects.filter(course_module=self.module)
         learningobjects_dict = { obj.id: obj for obj in learningobjects }
         # Can't use self.children for iteration, so this instead
-        flat_module = self.content.flat_module(self.module)
+        flat_module = self.points.flat_module(self.module)
         exercises = [entry for entry in flat_module if entry['type'] == 'exercise']
         for exercise in exercises:
             learningobj = learningobjects_dict[exercise['id']]
@@ -193,6 +242,19 @@ class LtiExerciseView(LtiSessionMixin, ExerciseView):
     def dispatch(self, request, *args, **kwargs):
         return super().dispatch(request, *args, **kwargs)
 
+    def get_common_objects(self) -> None:
+        super().get_common_objects()
+        upp = self.get_upper(self.current)
+        if upp:
+            self.upper_name = upp['name']
+            self.upper_link = upp['link']
+        else:
+            self.upper_name = None
+            self.upper_link = None
+        self.previous = self.adjust_visibility(self.previous)
+        self.next = self.adjust_visibility(self.next)
+        self.note('upper_name', 'upper_link')
+
     def post(self, request, *args, **kwargs):
         return super().post(request, *args, **kwargs, redirect_view='lti-submission')
 
@@ -229,7 +291,6 @@ def get_resource_objects(self):
 
     def get_common_objects(self):
         super().get_common_objects()
-        self.parse_lti_session_params()
         #FIXME do not include old, closed courses so that the course lists are reasonable
         if self.request.user.is_superuser:
             self.teaching_courses = CourseInstance.objects.all()


Test patch: