Skip to content

Commit

Permalink
feat: add CourseApp for Learning Assistant feature and register as en…
Browse files Browse the repository at this point in the history
…trypoint

This commit adds a concrete implementation of the CourseApp ABC from the CourseApps edx-platform Django app. This enables the Learning Assistant to be represented by a card on the Studio Pages & Resources pages. Please see the associated ADR for more details.

This commit also registers this CourseApp class as an entrypoint to the CourseApps edx-platfrom Django app. Because the CourseApps REST API is a part of the CMS, the Learning Assistant plugin was also added as entrypoint to the CMS.
  • Loading branch information
MichaelRoytman committed Jan 10, 2024
1 parent 5859798 commit 03ccf9a
Show file tree
Hide file tree
Showing 18 changed files with 476 additions and 13 deletions.
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ omit =
*admin.py
*static*
*templates*
learning_assistant/plugins.py
50 changes: 49 additions & 1 deletion learning_assistant/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from edx_django_utils.cache import get_cache_key

from learning_assistant.constants import ACCEPTED_CATEGORY_TYPES, CATEGORY_TYPE_MAP
from learning_assistant.models import CoursePrompt
from learning_assistant.data import LearningAssistantCourseEnabledData
from learning_assistant.models import CoursePrompt, LearningAssistantCourseEnabled
from learning_assistant.platform_imports import (
block_get_children,
block_leaf_filter,
get_single_block,
get_text_transcript,
learning_assistant_available,
traverse_block_pre_order,
)
from learning_assistant.text_utils import html_to_text
Expand Down Expand Up @@ -112,3 +114,49 @@ def get_block_content(request, user_id, course_id, unit_usage_key):
cache.set(cache_key, cache_data, getattr(settings, 'LEARNING_ASSISTANT_CACHE_TIMEOUT', 360))

return cache_data['content_length'], cache_data['content_items']


def learning_assistant_enabled(course_key):
"""
Return whether the Learning Assistant is enabled in the course represented by the course_key.
The Learning Assistant is enabled if the feature is available (i.e. appropriate CourseWaffleFlag is enabled) and
either there is no override in the LearningAssistantCourseEnabled table or there is an enabled value in the
LearningAssistantCourseEnabled table.
Arguments:
* course_key: (CourseKey): the course's key
Returns:
* bool: whether the Learning Assistant is enabled
"""
try:
obj = LearningAssistantCourseEnabled.objects.get(course_id=course_key)
enabled = obj.enabled
except LearningAssistantCourseEnabled.DoesNotExist:
# Currently, the Learning Assistant defaults to enabled if there is no override.
enabled = True

return learning_assistant_available(course_key) and enabled


def set_learning_assistant_enabled(course_key, enabled):
"""
Set whether the Learning Assistant is enabled and return a representation of the created data.
Arguments:
* course_key: (CourseKey): the course's key
* enabled (bool): whether the Learning Assistant should be enabled
Returns:
* bool: whether the Learning Assistant is enabled
"""
obj, _ = LearningAssistantCourseEnabled.objects.update_or_create(
course_id=course_key,
defaults={'enabled': enabled}
)

return LearningAssistantCourseEnabledData(
course_key=obj.course_id,
enabled=obj.enabled
)
15 changes: 15 additions & 0 deletions learning_assistant/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Data classes for the Learning Assistant application.
"""
from attrs import field, frozen, validators
from opaque_keys.edx.keys import CourseKey


@frozen
class LearningAssistantCourseEnabledData:
"""
Data class representing whether Learning Assistant is enabled in a course.
"""

course_key: CourseKey = field(validator=validators.instance_of(CourseKey))
enabled: bool = field(validator=validators.instance_of(bool))
36 changes: 36 additions & 0 deletions learning_assistant/platform_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,39 @@ def block_get_children(block):
# pylint: disable=import-error, import-outside-toplevel
from openedx.core.lib.graph_traversals import get_children
return get_children(block)


def learning_assistant_available(course_key):
"""
Return whether the Learning Assistant is available in the course represented by the course_key.
Note that this may be different than whether the Learning Assistant is enabled in the course. The value returned by
this fuction represenents whether the Learning Assistant is available in the course and, perhaps, whether it is
enabled. Course teams can disable the Learning Assistant via the LearningAssistantCourseEnabled model, so, in those
cases, the Learning Assistant may be available and disabled.
Arguments:
* course_key (CourseKey): the course's key
Returns:
* bool: whether the Learning Assistant feature is available
"""
# pylint: disable=import-error, import-outside-toplevel
from lms.djangoapps.courseware.toggles import learning_assistant_is_active
return learning_assistant_is_active(course_key)


def get_user_role(user, course_key):
"""
Return the role of the user on the edX platform.
Arguments:
* user (User): the user who's role to get
* course_key (CourseKey): the key of the course in which to get the user's role
Returns:
* bool: whether the Learning Assistant feature is available
"""
# pylint: disable=import-error, import-outside-toplevel
from lms.djangoapps.courseware.access import get_user_role as platform_get_user_role
return platform_get_user_role(user, course_key)
87 changes: 87 additions & 0 deletions learning_assistant/plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
Plugins for the Learning Assistant application.
"""
# pylint: disable=import-error
from openedx.core.djangoapps.course_apps.plugins import CourseApp

from learning_assistant import plugins_api


class LearningAssistantCourseApp(CourseApp):
"""
A CourseApp plugin representing the Learning Assistant feature.
Please see the associated ADR for more details.
"""

app_id = 'learning_assistant'
name = 'Learning Assistant'
description = 'TBD'
documentation_links = {}

@classmethod
def is_available(cls, course_key):
"""
Return a boolean indicating this course app's availability for a given course.
If an app is not available, it will not show up in the UI at all for that course,
and it will not be possible to enable/disable/configure it.
Args:
course_key (CourseKey): Course key for course whose availability is being checked.
Returns:
bool: Availability status of app.
"""
return plugins_api.is_available(course_key)

@classmethod
def is_enabled(cls, course_key):
"""
Return if this course app is enabled for the provided course.
Args:
course_key (CourseKey): The course key for the course you
want to check the status of.
Returns:
bool: The status of the course app for the specified course.
"""
return plugins_api.is_enabled(course_key)

@classmethod
def set_enabled(cls, course_key, enabled, user):
"""
Update the status of this app for the provided course and return the new status.
Args:
course_key (CourseKey): The course key for the course for which the app should be enabled.
enabled (bool): The new status of the app.
user (User): The user performing this operation.
Returns:
bool: The new status of the course app.
"""
return plugins_api.set_enabled(course_key, enabled, user)

@classmethod
def get_allowed_operations(cls, course_key, user=None):
"""
Return a dictionary of available operations for this app.
Not all apps will support being configured, and some may support
other operations via the UI. This will list, the minimum whether
the app can be enabled/disabled and whether it can be configured.
Args:
course_key (CourseKey): The course key for a course.
user (User): The user for which the operation is to be tested.
Returns:
A dictionary that has keys like 'enable', 'configure' etc
with values indicating whether those operations are allowed.
get_allowed_operations: function that returns a dictionary of the form
{'enable': <bool>, 'configure': <bool>}.
"""
return plugins_api.get_allowed_operations(course_key, user)
88 changes: 88 additions & 0 deletions learning_assistant/plugins_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Concrete implementations of abstract methods of the CourseApp plugin ABC, for use by the LearningAssistantCourseApp.
Because the LearningAssistantCourseApp plugin inherits from the CourseApp class, which is imported from the
edx-platform, we cannot test that plugin directly, because pytest will run outside the platform context.
Instead, the CourseApp abstract methods are implemented here and
imported into and used by the LearningAssistantCourseApp. This way, these implementations can be tested.
"""

from learning_assistant.api import learning_assistant_enabled, set_learning_assistant_enabled
from learning_assistant.platform_imports import get_user_role, learning_assistant_available
from learning_assistant.utils import user_role_is_staff


def is_available(course_key):
"""
Return a boolean indicating this course app's availability for a given course.
If an app is not available, it will not show up in the UI at all for that course,
and it will not be possible to enable/disable/configure it.
Args:
course_key (CourseKey): Course key for course whose availability is being checked.
Returns:
bool: Availability status of app.
"""
return learning_assistant_available(course_key)


def is_enabled(course_key):
"""
Return if this course app is enabled for the provided course.
Args:
course_key (CourseKey): The course key for the course you
want to check the status of.
Returns:
bool: The status of the course app for the specified course.
"""
return learning_assistant_enabled(course_key)


# pylint: disable=unused-argument
def set_enabled(course_key, enabled, user):
"""
Update the status of this app for the provided course and return the new status.
Args:
course_key (CourseKey): The course key for the course for which the app should be enabled.
enabled (bool): The new status of the app.
user (User): The user performing this operation.
Returns:
bool: The new status of the course app.
"""
obj = set_learning_assistant_enabled(course_key, enabled)

return obj.enabled


def get_allowed_operations(course_key, user=None):
"""
Return a dictionary of available operations for this app.
Not all apps will support being configured, and some may support
other operations via the UI. This will list, the minimum whether
the app can be enabled/disabled and whether it can be configured.
Args:
course_key (CourseKey): The course key for a course.
user (User): The user for which the operation is to be tested.
Returns:
A dictionary that has keys like 'enable', 'configure' etc
with values indicating whether those operations are allowed.
get_allowed_operations: function that returns a dictionary of the form
{'enable': <bool>, 'configure': <bool>}.
"""
if not user:
return {'configure': False, 'enable': False}
else:
user_role = get_user_role(user, course_key)
is_staff = user_role_is_staff(user_role)

return {'configure': False, 'enable': is_staff}
13 changes: 13 additions & 0 deletions learning_assistant/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,16 @@ def get_chat_response(system_list, message_list):
chat = 'Completion endpoint is not defined.'

return response_status, chat


def user_role_is_staff(role):
"""
Return whether the user role parameter represents that of a staff member.
Arguments:
* role (str): the user's role
Returns:
* bool: whether the user's role is that of a staff member
"""
return role in ('staff', 'instructor')
4 changes: 2 additions & 2 deletions learning_assistant/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from learning_assistant.api import get_setup_messages
from learning_assistant.serializers import MessageSerializer
from learning_assistant.utils import get_chat_response
from learning_assistant.utils import get_chat_response, user_role_is_staff

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -59,7 +59,7 @@ def post(self, request, course_id):
enrollment_mode = enrollment_object.mode if enrollment_object else None
if (
(enrollment_mode not in CourseMode.ALL_MODES)
and user_role not in ('staff', 'instructor')
and not user_role_is_staff(user_role)
):
return Response(
status=http_status.HTTP_403_FORBIDDEN,
Expand Down
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Core requirements for using this application
-c constraints.txt

attrs
Django # Web application framework
django-model-utils
djangorestframework
Expand Down
2 changes: 2 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#
asgiref==3.7.2
# via django
attrs==23.2.0
# via -r requirements/base.in
certifi==2023.11.17
# via requests
cffi==1.16.0
Expand Down
2 changes: 2 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ astroid==3.0.2
# -r requirements/quality.txt
# pylint
# pylint-celery
attrs==23.2.0
# via -r requirements/quality.txt
build==1.0.3
# via
# -r requirements/pip-tools.txt
Expand Down
Loading

0 comments on commit 03ccf9a

Please sign in to comment.