From d2c2fcdefe4e51193d753645677599a16fb750ed Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Wed, 23 Jun 2021 22:21:12 +0530 Subject: [PATCH] feat: Course Apps API [BD-38] [TNL-8103] [BB-2716] (#27542) * feat: Course Apps API This adds a new concept called course apps. These are exposed via a new "openedx.course_app" entrypoint, which helps the LMS and studio discover such apps and list them in a new rest api for the same. These course apps will drive the pages and resources view in the course authoring MFE. This system will track which apps are enabled and which are disabled. It also allows third-party apps to be listed here by using the plugin entrypoint. * Apply feedback from review --- docs/swagger.yaml | 132 ++++++++---- .../course_wiki/plugins/__init__.py | 65 ++++++ lms/djangoapps/courseware/plugins.py | 149 ++++++++++++++ lms/djangoapps/discussion/rest_api/api.py | 2 - lms/djangoapps/edxnotes/plugins.py | 58 +++++- .../grades/rest_api/v1/gradebook_views.py | 4 +- lms/djangoapps/grades/rest_api/v1/views.py | 2 +- .../program_enrollments/rest_api/v1/utils.py | 2 +- lms/djangoapps/teams/plugins.py | 50 ++++- .../content/learning_sequences/views.py | 15 +- .../core/djangoapps/course_apps/__init__.py | 0 openedx/core/djangoapps/course_apps/api.py | 45 +++++ openedx/core/djangoapps/course_apps/apps.py | 24 +++ .../course_apps/migrations/__init__.py | 0 openedx/core/djangoapps/course_apps/models.py | 3 + .../core/djangoapps/course_apps/plugins.py | 112 +++++++++++ .../course_apps/rest_api/__init__.py | 0 .../course_apps/rest_api/tests/__init__.py | 0 .../course_apps/rest_api/tests/test_views.py | 100 +++++++++ .../djangoapps/course_apps/rest_api/urls.py | 9 + .../course_apps/rest_api/v1/urls.py | 11 + .../course_apps/rest_api/v1/views.py | 190 ++++++++++++++++++ .../djangoapps/course_apps/tests/__init__.py | 0 .../djangoapps/course_apps/tests/test_api.py | 51 +++++ .../djangoapps/course_apps/tests/utils.py | 57 ++++++ .../core/djangoapps/course_apps/toggles.py | 7 + .../core/djangoapps/discussions/plugins.py | 61 ++++++ openedx/core/djangoapps/discussions/views.py | 28 +-- openedx/core/lib/api/tests/test_view_utils.py | 2 +- openedx/core/lib/api/view_utils.py | 73 ++++--- openedx/core/lib/teams_config.py | 12 +- setup.py | 11 + 32 files changed, 1157 insertions(+), 118 deletions(-) create mode 100644 lms/djangoapps/courseware/plugins.py create mode 100644 openedx/core/djangoapps/course_apps/__init__.py create mode 100644 openedx/core/djangoapps/course_apps/api.py create mode 100644 openedx/core/djangoapps/course_apps/apps.py create mode 100644 openedx/core/djangoapps/course_apps/migrations/__init__.py create mode 100644 openedx/core/djangoapps/course_apps/models.py create mode 100644 openedx/core/djangoapps/course_apps/plugins.py create mode 100644 openedx/core/djangoapps/course_apps/rest_api/__init__.py create mode 100644 openedx/core/djangoapps/course_apps/rest_api/tests/__init__.py create mode 100644 openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py create mode 100644 openedx/core/djangoapps/course_apps/rest_api/urls.py create mode 100644 openedx/core/djangoapps/course_apps/rest_api/v1/urls.py create mode 100644 openedx/core/djangoapps/course_apps/rest_api/v1/views.py create mode 100644 openedx/core/djangoapps/course_apps/tests/__init__.py create mode 100644 openedx/core/djangoapps/course_apps/tests/test_api.py create mode 100644 openedx/core/djangoapps/course_apps/tests/utils.py create mode 100644 openedx/core/djangoapps/course_apps/toggles.py create mode 100644 openedx/core/djangoapps/discussions/plugins.py diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2a4b4f5fa4..93669aca7a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -16,6 +16,50 @@ securityDefinitions: security: - Basic: [] paths: + /agreements/v1/integrity_signature/{course_id}: + get: + operationId: agreements_v1_integrity_signature_read + summary: In order to check whether the user has signed the integrity agreement + for a given course. + description: |- + Should return the following: + username (str) + course_id (str) + created_at (str) + + If a username is not given, it should default to the requesting user (or masqueraded user). + Only staff should be able to access this endpoint for other users. + parameters: [] + responses: + '200': + description: '' + tags: + - agreements + post: + operationId: agreements_v1_integrity_signature_create + description: |- + Create an integrity signature for the requesting user and course. If a signature + already exists, returns the existing signature instead of creating a new one. + + /api/agreements/v1/integrity_signature/{course_id} + + Example response: + { + username: "janedoe", + course_id: "org.2/course_2/Run_2", + created_at: "2021-04-23T18:25:43.511Z" + } + parameters: [] + responses: + '201': + description: '' + tags: + - agreements + parameters: + - name: course_id + in: path + required: true + type: string /badges/v1/assertions/user/{username}/: get: operationId: badges_v1_assertions_user_read @@ -1316,15 +1360,14 @@ paths: 'Pass' is not included. studio_url: (str) a str of the link to the grading in studio for the course verification_data: an object containing - link: (str) the link to either start or retry verification - status: (str) the status of the verification - status_date: (str) the date time string of when the verification status was set + link: (str) the link to either start or retry ID verification + status: (str) the status of the ID verification + status_date: (str) the date time string of when the ID verification status was set **Returns** * 200 on success with above fields. - * 302 if the user is not enrolled. - * 401 if the user is not authenticated. + * 401 if the user is not authenticated or not enrolled. * 404 if the course is not available or cannot be seen. parameters: [] responses: @@ -2517,6 +2560,7 @@ paths: * can_load_course: Whether the user can view the course (AccessResponse object) * is_staff: Whether the effective user has staff access to the course * original_user_is_staff: Whether the original user has staff access to the course + * can_view_legacy_courseware: Indicates whether the user is able to see the legacy courseware view * user_has_passing_grade: Whether or not the effective user's grade is equal to or above the courses minimum passing grade * course_exit_page_is_active: Flag for the learning mfe on whether or not the course exit page should display @@ -5992,35 +6036,6 @@ paths: tags: - toggles parameters: [] - /user/v1/account/login_session/: - get: - operationId: user_v1_account_login_session_list - description: HTTP end-points for logging in users. - parameters: [] - responses: - '200': - description: '' - tags: - - user - post: - operationId: user_v1_account_login_session_create - summary: Log in a user. - description: |- - See `login_user` for details. - - Example Usage: - - POST /api/user/v1/login_session - with POST params `email`, `password`. - - 200 {'success': true} - parameters: [] - responses: - '201': - description: '' - tags: - - user - parameters: [] /user/v1/account/password_reset/: get: operationId: user_v1_account_password_reset_list @@ -6861,6 +6876,39 @@ paths: tags: - user parameters: [] + /user/{api_version}/account/login_session/: + get: + operationId: user_account_login_session_list + description: HTTP end-points for logging in users. + parameters: [] + responses: + '200': + description: '' + tags: + - user + post: + operationId: user_account_login_session_create + summary: Log in a user. + description: |- + See `login_user` for details. + + Example Usage: + + POST /api/user/v1/login_session + with POST params `email`, `password`. + + 200 {'success': true} + parameters: [] + responses: + '201': + description: '' + tags: + - user + parameters: + - name: api_version + in: path + required: true + type: string /val/v0/videos/: get: operationId: val_v0_videos_list @@ -7394,6 +7442,14 @@ definitions: - celebrations type: object properties: + can_show_upgrade_sock: + title: Can show upgrade sock + type: string + readOnly: true + verified_mode: + title: Verified mode + type: string + readOnly: true course_id: title: Course id type: string @@ -7815,6 +7871,14 @@ definitions: - verification_data type: object properties: + can_show_upgrade_sock: + title: Can show upgrade sock + type: string + readOnly: true + verified_mode: + title: Verified mode + type: string + readOnly: true certificate_data: $ref: '#/definitions/CertificateData' completion_summary: diff --git a/lms/djangoapps/course_wiki/plugins/__init__.py b/lms/djangoapps/course_wiki/plugins/__init__.py index e69de29bb2..9c4e35b8c7 100644 --- a/lms/djangoapps/course_wiki/plugins/__init__.py +++ b/lms/djangoapps/course_wiki/plugins/__init__.py @@ -0,0 +1,65 @@ +"""Module with the course app configuration for the Wiki.""" +from typing import Dict, Optional, TYPE_CHECKING + +from django.conf import settings +from django.utils.translation import ugettext_noop as _ +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.course_apps.plugins import CourseApp + +# Import the User model only for type checking since importing it at runtime +# will prevent the app from starting since the model is imported before +# Django's machinery is ready. +if TYPE_CHECKING: + from django.contrib.auth import get_user_model + User = get_user_model() + +WIKI_ENABLED = settings.WIKI_ENABLED + + +class WikiCourseApp(CourseApp): + """ + Course app for the Wiki. + """ + + app_id = "wiki" + name = _("Wiki") + description = _("Enable learners to access, and collaborate on information about your course.") + + @classmethod + def is_available(cls, course_key: CourseKey) -> bool: # pylint: disable=unused-argument + """ + Returns if the app is available for the course. + + The wiki is available for all courses or none of them depending on the a Django setting. + """ + return WIKI_ENABLED + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: # pylint: disable=unused-argument + """ + Returns if the wiki is available for the course. + + The wiki currently cannot be enabled or disabled on a per-course basis. + """ + return WIKI_ENABLED + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + """ + The wiki cannot be enabled or disabled. + """ + # Currently, you cannot enable/disable wiki via the API + raise ValueError("Wiki cannot be enabled/disabled vis this API.") + + @classmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional['User'] = None) -> Dict[str, bool]: + """ + Returns the operations you can perform on the wiki. + """ + return { + # The wiki cannot be enabled/disabled via the API yet. + "enable": False, + # There is nothing to configure for Wiki yet. + "configure": False, + } diff --git a/lms/djangoapps/courseware/plugins.py b/lms/djangoapps/courseware/plugins.py new file mode 100644 index 0000000000..87e246156c --- /dev/null +++ b/lms/djangoapps/courseware/plugins.py @@ -0,0 +1,149 @@ +"""Course app config for courseware apps.""" +from typing import Dict, Optional + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_noop as _ +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.django import modulestore + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.course_apps.plugins import CourseApp +from openedx.core.lib.courses import get_course_by_id + +User = get_user_model() + +TEXTBOOK_ENABLED = settings.FEATURES.get("ENABLE_TEXTBOOK", False) + + +class ProgressCourseApp(CourseApp): + """ + Course app config for progress app. + """ + + app_id = "progress" + name = _("Progress") + description = _("Allow students to track their progress throughout the course.") + + @classmethod + def is_available(cls, course_key: CourseKey) -> bool: + """ + The progress course app is always available. + """ + return True + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: + """ + The progress course status is stored in the course module. + """ + return not CourseOverview.get_from_id(course_key).hide_progress_tab + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + """ + The progress course enabled/disabled status is stored in the course module. + """ + course = get_course_by_id(course_key) + course.hide_progress_tab = not enabled + modulestore().update_item(course, user.id) + return enabled + + @classmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional[User] = None) -> Dict[str, bool]: + """ + Returns the allowed operations for the app. + """ + return { + "enable": True, + "configure": True, + } + + +class TextbooksCourseApp(CourseApp): + """ + Course app config for textbooks app. + """ + + app_id = "textbooks" + name = _("Textbooks") + description = _("Provide links to applicable resources for your course.") + + @classmethod + def is_available(cls, course_key: CourseKey) -> bool: # pylint: disable=unused-argument + """ + The textbook app can be made available globally using a value in features. + """ + return TEXTBOOK_ENABLED + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: # pylint: disable=unused-argument + """ + Returns if the textbook app is globally enabled. + """ + return TEXTBOOK_ENABLED + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + """ + The textbook app can be globally enabled/disabled. + + Currently, it isn't possible to enable/disable this app on a per-course basis. + """ + raise ValueError("The textbook app can not be enabled/disabled for a single course.") + + @classmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional[User] = None) -> Dict[str, bool]: + """ + Returns the allowed operations for the app. + """ + return { + # Either the app is available and configurable or not. You cannot disable it from the API yet. + "enable": False, + "configure": True, + } + + +class CalculatorCourseApp(CourseApp): + """ + Course App config for calculator app. + """ + + app_id = "calculator" + name = _("Calculator") + description = _("Provide an in-browser calculator that supports simple and complex calculations.") + + @classmethod + def is_available(cls, course_key: CourseKey) -> bool: + """ + Calculator is available for all courses. + """ + return True + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: + """ + Get calculator enabled status from course overview model. + """ + return CourseOverview.get_from_id(course_key).show_calculator + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + """ + Update calculator enabled status in modulestore. + """ + course = get_course_by_id(course_key) + course.show_calculator = enabled + modulestore().update_item(course, user.id) + return enabled + + @classmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional[User] = None) -> Dict[str, bool]: + """ + Get allowed operations for calculator app. + """ + return { + "enable": True, + # There is nothing to configure for calculator yet. + "configure": False, + } diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index b47b020a24..66fa5367ca 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -63,8 +63,6 @@ from openedx.core.djangoapps.django_comment_common.signals import ( thread_voted ) from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.djangoapps.user_api.accounts.views import \ - AccountViewSet # lint-amnesty, pylint: disable=unused-import from openedx.core.lib.exceptions import CourseNotFoundError, DiscussionNotFoundError, PageNotFoundError diff --git a/lms/djangoapps/edxnotes/plugins.py b/lms/djangoapps/edxnotes/plugins.py index 53bed872b3..f8ddec8efe 100644 --- a/lms/djangoapps/edxnotes/plugins.py +++ b/lms/djangoapps/edxnotes/plugins.py @@ -1,12 +1,20 @@ """ Registers the "edX Notes" feature for the edX platform. """ - +from typing import Dict, Optional from django.conf import settings -from django.utils.translation import ugettext_noop +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_noop as _ +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.django import modulestore from lms.djangoapps.courseware.tabs import EnrolledTab +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.course_apps.plugins import CourseApp +from openedx.core.lib.courses import get_course_by_id + +User = get_user_model() class EdxNotesTab(EnrolledTab): @@ -15,7 +23,7 @@ class EdxNotesTab(EnrolledTab): """ type = "edxnotes" - title = ugettext_noop("Notes") + title = _("Notes") view_name = "edxnotes" @classmethod @@ -36,3 +44,47 @@ class EdxNotesTab(EnrolledTab): return False return course.edxnotes + + +class EdxNotesCourseApp(CourseApp): + """ + Course app for edX notes. + """ + + app_id = "edxnotes" + name = _("Notes") + description = _("Allow students to take notes.") + + @classmethod + def is_available(cls, course_key: CourseKey) -> bool: # pylint: disable=unused-argument + """ + EdX notes availability is currently globally controlled via a feature setting. + """ + return settings.FEATURES.get("ENABLE_EDXNOTES", False) + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: # pylint: disable=unused-argument + """ + Get enabled/disabled status from modulestore. + """ + return CourseOverview.get_from_id(course_key).edxnotes + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + """ + Enable/disable edxnotes in the modulestore. + """ + course = get_course_by_id(course_key) + course.edxnotes = enabled + modulestore().update_item(course, user.id) + return enabled + + @classmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional[User] = None) -> Dict[str, bool]: + """ + Returns allowed operations for edxnotes app. + """ + return { + "enable": True, + "configure": True, + } diff --git a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py index de00adc7b0..eb32de8c1d 100644 --- a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py @@ -505,7 +505,7 @@ class GradebookView(GradeViewMixin, PaginatedAPIView): return user_entry - @verify_course_exists + @verify_course_exists("Requested grade for unknown course {course}") @verify_writable_gradebook_enabled @course_author_access_required def get(self, request, course_key): # lint-amnesty, pylint: disable=too-many-statements @@ -790,7 +790,7 @@ class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView): ] """ - @verify_course_exists + @verify_course_exists("Requested grade for unknown course {course}") @verify_writable_gradebook_enabled @course_author_access_required def post(self, request, course_key): diff --git a/lms/djangoapps/grades/rest_api/v1/views.py b/lms/djangoapps/grades/rest_api/v1/views.py index f865c3b3db..5adada4e8c 100644 --- a/lms/djangoapps/grades/rest_api/v1/views.py +++ b/lms/djangoapps/grades/rest_api/v1/views.py @@ -101,7 +101,7 @@ class CourseGradesView(GradeViewMixin, PaginatedAPIView): required_scopes = ['grades:read'] - @verify_course_exists + @verify_course_exists("Requested grade for unknown course {course}") def get(self, request, course_id=None): """ Gets a course progress status. diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py index 83707a81eb..d1b08b35d7 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py @@ -156,7 +156,7 @@ def verify_course_exists_and_in_program(view_func): """ @wraps(view_func) @verify_program_exists - @verify_course_exists + @verify_course_exists() def wrapped_function(self, *args, **kwargs): """ Wraps view function diff --git a/lms/djangoapps/teams/plugins.py b/lms/djangoapps/teams/plugins.py index 2f53fc1f39..469656055c 100644 --- a/lms/djangoapps/teams/plugins.py +++ b/lms/djangoapps/teams/plugins.py @@ -1,22 +1,29 @@ """ Definition of the course team feature. """ +from typing import Dict, Optional - -from django.utils.translation import ugettext_noop +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_noop as _ +from opaque_keys.edx.keys import CourseKey from lms.djangoapps.courseware.tabs import EnrolledTab - +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.course_apps.plugins import CourseApp from . import is_feature_enabled +User = get_user_model() + + class TeamsTab(EnrolledTab): """ The representation of the course teams view type. """ type = "teams" - title = ugettext_noop("Teams") + title = _("Teams") view_name = "teams_dashboard" @classmethod @@ -31,3 +38,38 @@ class TeamsTab(EnrolledTab): return False return is_feature_enabled(course) + + +class TeamsCourseApp(CourseApp): + """ + Course app for teams. + """ + + app_id = "teams" + name = _("Teams") + description = _("Leverage teams to allow learners to connect by topic of interest.") + + @classmethod + def is_available(cls, course_key: CourseKey) -> bool: + """ + The teams app is currently available globally based on a feature setting. + """ + return settings.FEATURES.get("ENABLE_TEAMS", False) + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: + return CourseOverview.get_from_id(course_key).teams_enabled + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: User) -> bool: + raise ValueError("Teams cannot be enabled/disabled via this API.") + + @classmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional[User] = None) -> Dict[str, bool]: + """ + Return allowed operations for teams app. + """ + return { + "enable": False, + "configure": True, + } diff --git a/openedx/core/djangoapps/content/learning_sequences/views.py b/openedx/core/djangoapps/content/learning_sequences/views.py index 69abe46985..c187b55f58 100644 --- a/openedx/core/djangoapps/content/learning_sequences/views.py +++ b/openedx/core/djangoapps/content/learning_sequences/views.py @@ -9,13 +9,12 @@ from django.conf import settings from django.contrib.auth import get_user_model from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey from rest_framework import serializers from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.response import Response from rest_framework.views import APIView +from openedx.core.lib.api.view_utils import validate_course_key from .api import get_user_course_outline_details from .api.permissions import can_call_public_api from .data import CourseOutlineData @@ -156,7 +155,7 @@ class CourseOutlineView(APIView): """ # Translate input params and do course key validation (will cause HTTP # 400 error if an invalid CourseKey was entered, instead of 404). - course_key = self._validate_course_key(course_key_str) + course_key = validate_course_key(course_key_str) at_time = datetime.now(timezone.utc) if not can_call_public_api(request.user, course_key): @@ -172,16 +171,6 @@ class CourseOutlineView(APIView): serializer = self.UserCourseOutlineDataSerializer(user_course_outline_details) return Response(serializer.data) - def _validate_course_key(self, course_key_str): - """Validate the Course Key and raise a ValidationError if it fails.""" - try: - course_key = CourseKey.from_string(course_key_str) - except InvalidKeyError as err: - raise serializers.ValidationError(f"{course_key_str} is not a valid CourseKey") from err - if course_key.deprecated: - raise serializers.ValidationError("Deprecated CourseKeys (Org/Course/Run) are not supported.") - return course_key - def _determine_user(self, request): """ Requesting for a different user (easiest way to test for students) diff --git a/openedx/core/djangoapps/course_apps/__init__.py b/openedx/core/djangoapps/course_apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/course_apps/api.py b/openedx/core/djangoapps/course_apps/api.py new file mode 100644 index 0000000000..4ce3f8bff1 --- /dev/null +++ b/openedx/core/djangoapps/course_apps/api.py @@ -0,0 +1,45 @@ +""" +Python APIs for Course Apps. +""" +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey + +from .plugins import CourseAppsPluginManager + + +User = get_user_model() + + +def is_course_app_enabled(course_key: CourseKey, app_id: str) -> bool: + """ + Return if the app with the specified `app_id` is enabled for the + specified course. + + Args: + course_key (CourseKey): Course key for course + app_id (str): The app id for a course app + + Returns: + True or False depending on if the course app is enabled or not. + """ + course_app = CourseAppsPluginManager.get_plugin(app_id) + is_enabled = course_app.is_enabled(course_key) + return is_enabled + + +def set_course_app_enabled(course_key: CourseKey, app_id: str, enabled: bool, user: User) -> bool: + """ + Enable/disable a course app. + + Args: + course_key (CourseKey): ID of course to operate on + app_id (str): The app ID of the app to enabled/disable + enabled (bool): The enable/disable status to apply + user (User): The user performing the operation. + + Returns: + The final enabled/disabled status of the app. + """ + course_app = CourseAppsPluginManager.get_plugin(app_id) + enabled = course_app.set_enabled(course_key, user=user, enabled=enabled) + return enabled diff --git a/openedx/core/djangoapps/course_apps/apps.py b/openedx/core/djangoapps/course_apps/apps.py new file mode 100644 index 0000000000..ef03761e6d --- /dev/null +++ b/openedx/core/djangoapps/course_apps/apps.py @@ -0,0 +1,24 @@ +""" +Pluggable app config for course apps. +""" +from django.apps import AppConfig +from edx_django_utils.plugins import PluginURLs + +from openedx.core.djangoapps.plugins.constants import ProjectType + + +class CourseAppsConfig(AppConfig): + """ + Configuration class for Course Apps. + """ + + name = "openedx.core.djangoapps.course_apps" + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.CMS: { + PluginURLs.NAMESPACE: "course_apps_api", + PluginURLs.REGEX: r"^api/course_apps/", + PluginURLs.RELATIVE_PATH: "rest_api.urls", + } + }, + } diff --git a/openedx/core/djangoapps/course_apps/migrations/__init__.py b/openedx/core/djangoapps/course_apps/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/course_apps/models.py b/openedx/core/djangoapps/course_apps/models.py new file mode 100644 index 0000000000..327a8dd707 --- /dev/null +++ b/openedx/core/djangoapps/course_apps/models.py @@ -0,0 +1,3 @@ +""" +Models for course apps. +""" diff --git a/openedx/core/djangoapps/course_apps/plugins.py b/openedx/core/djangoapps/course_apps/plugins.py new file mode 100644 index 0000000000..aa4f772b3d --- /dev/null +++ b/openedx/core/djangoapps/course_apps/plugins.py @@ -0,0 +1,112 @@ +""" +Course Apps plugin base class and plugin manager. +""" +from typing import Dict, Iterator, Optional + +from abc import ABC, abstractmethod +from edx_django_utils.plugins import PluginManager +from opaque_keys.edx.keys import CourseKey + + +# Stevedore extension point namespaces +COURSE_APPS_PLUGIN_NAMESPACE = "openedx.course_app" + + +class CourseApp(ABC): + """ + Abstract base class for all course app plugins. + """ + + # A unique ID for the app. + app_id: str = "" + # A friendly name for the app. + name: str = "" + # A description for the app. + description: str = "" + + @classmethod + @abstractmethod + def is_available(cls, course_key: CourseKey) -> bool: + """ + Returns 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. + """ + + @classmethod + @abstractmethod + def is_enabled(cls, course_key: CourseKey) -> bool: + """ + 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. + """ + + @classmethod + @abstractmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + """ + 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. + """ + + @classmethod + @abstractmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional['User'] = None) -> Dict[str, bool]: + """ + Returns 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. + """ + + +class CourseAppsPluginManager(PluginManager): + """ + Plugin manager to get all course all plugins. + """ + + NAMESPACE = COURSE_APPS_PLUGIN_NAMESPACE + + @classmethod + def get_apps_available_for_course(cls, course_key: CourseKey) -> Iterator[CourseApp]: + """ + Yields all course apps that are available for the provided course. + + Args: + course_key (CourseKey): The course key for which the list of apps is to be yielded. + + Yields: + CourseApp: A CourseApp plugin instance. + """ + for plugin in super().get_available_plugins().values(): + if plugin.is_available(course_key): + yield plugin diff --git a/openedx/core/djangoapps/course_apps/rest_api/__init__.py b/openedx/core/djangoapps/course_apps/rest_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/course_apps/rest_api/tests/__init__.py b/openedx/core/djangoapps/course_apps/rest_api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py new file mode 100644 index 0000000000..2c1e615229 --- /dev/null +++ b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py @@ -0,0 +1,100 @@ +""" +Tests for the rest api for course apps. +""" +import contextlib +import json +from unittest import mock + +import ddt +from django.test import Client +from django.urls import reverse +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.student.roles import CourseStaffRole +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_cms +from ...tests.utils import make_test_course_app + + +@skip_unless_cms +@ddt.ddt +class CourseAppsRestApiTest(SharedModuleStoreTestCase): + """ + Tests for the rest api for course apps. + """ + + def setUp(self): + super().setUp() + store = ModuleStoreEnum.Type.split + self.course = CourseFactory.create(default_store=store) + self.instructor = UserFactory() + self.user = UserFactory() + self.client = Client() + self.client.login(username=self.instructor.username, password="test") + self.url = reverse("course_apps_api:v1:course_apps", kwargs=dict(course_id=self.course.id)) + CourseStaffRole(self.course.id).add_users(self.instructor) + + @contextlib.contextmanager + def _setup_plugin_mock(self): + """ + Context manager that patches get_available_plugins to return test plugins. + """ + patcher = mock.patch("openedx.core.djangoapps.course_apps.plugins.PluginManager.get_available_plugins") + mock_get_available_plugins = patcher.start() + mock_get_available_plugins.return_value = { + "app1": make_test_course_app(app_id="app1", name="App One", is_available=True), + "app2": make_test_course_app(app_id="app2", name="App Two", is_available=True), + "app3": make_test_course_app(app_id="app3", name="App Three", is_available=False), + "app4": make_test_course_app(app_id="app4", name="App Four", is_available=True), + } + yield + patcher.stop() + + def test_only_show_available_apps(self): + """ + Tests that only available apps show up in the API response. + """ + with self._setup_plugin_mock(): + response = self.client.get(self.url) + data = json.loads(response.content.decode("utf-8")) + # Make sure that "app3" doesn't show up since it isn't available. + assert len(data) == 3 + assert all(app["id"] != "app3" for app in data) + + @ddt.data(True, False) + def test_update_status_success(self, enabled): + """ + Tests successful update response + """ + with self._setup_plugin_mock(): + response = self.client.patch(self.url, {"id": "app1", "enabled": enabled}, content_type="application/json") + data = json.loads(response.content.decode("utf-8")) + assert "enabled" in data + assert data["enabled"] == enabled + assert data["id"] == "app1" + + def test_update_invalid_enabled(self): + """ + Tests that an invalid or missing enabled value raises an error response. + """ + with self._setup_plugin_mock(): + response = self.client.patch(self.url, {"id": "app1"}, content_type="application/json") + assert response.status_code == 400 + data = json.loads(response.content.decode("utf-8")) + assert "developer_message" in data + # Check that there is an issue with the enabled field + assert "enabled" in data["developer_message"] + + @ddt.data("non-app", None, "app3") + def test_update_invalid_appid(self, app_id): + """ + Tests that an invalid appid raises an error response""" + with self._setup_plugin_mock(): + response = self.client.patch(self.url, {"id": app_id, "enabled": True}, content_type="application/json") + assert response.status_code == 400 + data = json.loads(response.content.decode("utf-8")) + assert "developer_message" in data + # Check that there is an issue with the ID field + assert "id" in data["developer_message"] diff --git a/openedx/core/djangoapps/course_apps/rest_api/urls.py b/openedx/core/djangoapps/course_apps/rest_api/urls.py new file mode 100644 index 0000000000..c4e47b068b --- /dev/null +++ b/openedx/core/djangoapps/course_apps/rest_api/urls.py @@ -0,0 +1,9 @@ +""" +API urls for course app v1 APIs. +""" +from django.urls import include, path +from .v1 import urls as v1_apis + +urlpatterns = [ + path("v1/", include(v1_apis, namespace="v1")), +] diff --git a/openedx/core/djangoapps/course_apps/rest_api/v1/urls.py b/openedx/core/djangoapps/course_apps/rest_api/v1/urls.py new file mode 100644 index 0000000000..95d5c6e89f --- /dev/null +++ b/openedx/core/djangoapps/course_apps/rest_api/v1/urls.py @@ -0,0 +1,11 @@ +# lint-amnesty, pylint: disable=missing-module-docstring +from django.urls import re_path + +from openedx.core.constants import COURSE_ID_PATTERN +from .views import CourseAppsView + +app_name = "openedx.core.djangoapps.course_apps" + +urlpatterns = [ + re_path(fr"^apps/{COURSE_ID_PATTERN}$", CourseAppsView.as_view(), name="course_apps"), +] diff --git a/openedx/core/djangoapps/course_apps/rest_api/v1/views.py b/openedx/core/djangoapps/course_apps/rest_api/v1/views.py new file mode 100644 index 0000000000..ea6798f9e0 --- /dev/null +++ b/openedx/core/djangoapps/course_apps/rest_api/v1/views.py @@ -0,0 +1,190 @@ +import logging +from typing import Dict + +from django.contrib.auth import get_user_model +from edx_api_doc_tools import path_parameter, schema +from edx_django_utils.plugins import PluginError +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from opaque_keys.edx.keys import CourseKey +from rest_framework import serializers, views +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.response import Response + +from common.djangoapps.student.auth import has_studio_write_access +from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, validate_course_key, verify_course_exists +from ...api import is_course_app_enabled, set_course_app_enabled +from ...plugins import CourseApp, CourseAppsPluginManager + +User = get_user_model() +log = logging.getLogger(__name__) + + +class HasStudioWriteAccess(BasePermission): + """ + Check if the user has write access to studio. + """ + + def has_permission(self, request, view): + """ + Check if the user has write access to studio. + """ + user = request.user + course_key_string = view.kwargs.get("course_id") + course_key = validate_course_key(course_key_string) + return has_studio_write_access(user, course_key) + + +class CourseAppSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer for course app data. + """ + + id = serializers.CharField(read_only=True, help_text="Unique ID for course app.") + enabled = serializers.BooleanField( + required=True, + help_text="Whether the course app is enabled for the specified course.", + ) + name = serializers.CharField(read_only=True, help_text="Friendly name of the course app.") + description = serializers.CharField(read_only=True, help_text="A friendly description of what the course app does.") + legacy_link = serializers.URLField(required=False, help_text="A link to the course app in the legacy studio view.") + allowed_operations = serializers.DictField( + read_only=True, + help_text="What all operations are supported by the app.", + ) + + def to_representation(self, instance: CourseApp) -> Dict: + course_key = self.context.get("course_key") + user = self.context.get("user") + data = { + "id": instance.app_id, + "enabled": is_course_app_enabled(course_key, instance.app_id), + "name": instance.name, + "description": instance.description, + "allowed_operations": instance.get_allowed_operations(course_key, user), + } + if hasattr(instance, "legacy_link"): + data["legacy_link"] = instance.legacy_link(course_key) + return data + + +class CourseAppsView(DeveloperErrorViewMixin, views.APIView): + """ + A view for getting a list of all apps available for a course. + """ + + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (HasStudioWriteAccess,) + + @schema( + parameters=[ + path_parameter("course_id", str, description="Course Key"), + ], + responses={ + 200: CourseAppSerializer, + 401: "The requester is not authenticated.", + 403: "The requester does not have staff access access to the specified course", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists("Requested apps for unknown course {course}") + def get(self, request: Request, course_id: str): + """ + Get a list of all the course apps available for a course. + + **Example Response** + + GET /api/course_apps/v1/apps/{course_id} + + ```json + [ + { + "allowed_operations": { + "configure": false, + "enable": true + }, + "description": "Provide an in-browser calculator that supports simple and complex calculations.", + "enabled": false, + "id": "calculator", + "name": "Calculator" + }, + { + "allowed_operations": { + "configure": true, + "enable": true + }, + "description": "Encourage participation and engagement in your course with discussion forums.", + "enabled": false, + "id": "discussion", + "name": "Discussion" + }, + ... + ] + ``` + """ + course_key = CourseKey.from_string(course_id) + course_apps = CourseAppsPluginManager.get_apps_available_for_course(course_key) + serializer = CourseAppSerializer( + course_apps, many=True, context={"course_key": course_key, "user": request.user} + ) + return Response(serializer.data) + + @schema( + parameters=[ + path_parameter("course_id", str, description="Course Key"), + ], + responses={ + 200: CourseAppSerializer, + 401: "The requester is not authenticated.", + 403: "The requester does not have staff access access to the specified course", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists("Requested apps for unknown course {course}") + def patch(self, request: Request, course_id: str): + """ + Enable/disable a course app. + + **Example Response** + + PATCH /api/course_apps/v1/apps/{course_id} { + "id": "wiki", + "enabled": true + } + + ```json + { + "allowed_operations": { + "configure": false, + "enable": false + }, + "description": "Enable learners to access, and collaborate on information about your course.", + "enabled": true, + "id": "wiki", + "name": "Wiki" + } + ``` + """ + course_key = CourseKey.from_string(course_id) + app_id = request.data.get("id") + enabled = request.data.get("enabled") + if app_id is None: + raise ValidationError({"id": "App id is missing"}) + if enabled is None: + raise ValidationError({"enabled": "Must provide value for `enabled` field."}) + try: + course_app = CourseAppsPluginManager.get_plugin(app_id) + except PluginError: + course_app = None + if not course_app or not course_app.is_available(course_key): + raise ValidationError({"id": "Invalid app ID"}) + set_course_app_enabled(course_key=course_key, app_id=app_id, enabled=enabled, user=request.user) + serializer = CourseAppSerializer(course_app, context={"course_key": course_key, "user": request.user}) + return Response(serializer.data) diff --git a/openedx/core/djangoapps/course_apps/tests/__init__.py b/openedx/core/djangoapps/course_apps/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/course_apps/tests/test_api.py b/openedx/core/djangoapps/course_apps/tests/test_api.py new file mode 100644 index 0000000000..3f4868783e --- /dev/null +++ b/openedx/core/djangoapps/course_apps/tests/test_api.py @@ -0,0 +1,51 @@ +""" +Tests for the python api for course apps. +""" +from unittest import mock +from unittest.mock import Mock + +import ddt +from django.test import TestCase +from opaque_keys.edx.locator import CourseLocator + +from .utils import make_test_course_app +from ..api import is_course_app_enabled, set_course_app_enabled + + +@ddt.ddt +@mock.patch("openedx.core.djangoapps.course_apps.api.CourseAppsPluginManager.get_plugin") +class CourseAppsPythonAPITest(TestCase): + """ + Tests for the python api for course apps. + """ + + def setUp(self) -> None: + super().setUp() + self.course_key = CourseLocator(org="org", course="course", run="run") + self.default_app_id = "test-app" + + @ddt.data(True, False) + def test_plugin_enabled(self, enabled, get_plugin): + """ + Test that the is_enabled value is used. + """ + CourseApp = make_test_course_app(is_available=True) + get_plugin.return_value = CourseApp + # Set contradictory value in existing CourseAppStatus to ensure that the `is_enabled` value is + # being used. + mock_is_enabled = Mock(return_value=enabled) + with mock.patch.object(CourseApp, "is_enabled", mock_is_enabled, create=True): + assert is_course_app_enabled(self.course_key, "test-app") == enabled + mock_is_enabled.assert_called() + + @ddt.data(True, False) + def test_set_plugin_enabled(self, enabled, get_plugin): + """ + Test the behaviour of set_course_app_enabled. + """ + CourseApp = make_test_course_app(is_available=True) + get_plugin.return_value = CourseApp + mock_set_enabled = Mock(return_value=enabled) + with mock.patch.object(CourseApp, "set_enabled", mock_set_enabled, create=True): + assert set_course_app_enabled(self.course_key, "test-app", enabled, Mock()) == enabled + mock_set_enabled.assert_called() diff --git a/openedx/core/djangoapps/course_apps/tests/utils.py b/openedx/core/djangoapps/course_apps/tests/utils.py new file mode 100644 index 0000000000..49bae28c21 --- /dev/null +++ b/openedx/core/djangoapps/course_apps/tests/utils.py @@ -0,0 +1,57 @@ +""" +Test utilities for course apps. +""" +from typing import Type + +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.course_apps.plugins import CourseApp + + +def make_test_course_app( + app_id: str = "test-app", + name: str = "Test Course App", + description: str = "Test Course App Description", + is_available: bool = True, +) -> Type[CourseApp]: + """ + Creates a test plugin entrypoint based on provided parameters.""" + + class TestCourseApp(CourseApp): + """ + Course App Config for use in tests. + """ + + app_id = "" + name = "" + description = "" + _enabled = {} + + @classmethod + def is_available(cls, course_key): # pylint=disable=unused-argument + """ + Return value provided to function""" + return is_available + + @classmethod + def get_allowed_operations(cls, course_key, user=None): # pylint=disable=unused-argument + """ + Return dummy values for allowed operations.""" + return { + "enable": True, + "configure": True, + } + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + cls._enabled[course_key] = enabled + return enabled + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: + return cls._enabled.get(course_key, False) + + TestCourseApp.app_id = app_id + TestCourseApp.name = name + TestCourseApp.description = description + return TestCourseApp diff --git a/openedx/core/djangoapps/course_apps/toggles.py b/openedx/core/djangoapps/course_apps/toggles.py new file mode 100644 index 0000000000..d2100ce361 --- /dev/null +++ b/openedx/core/djangoapps/course_apps/toggles.py @@ -0,0 +1,7 @@ +""" +Toggles for course apps. +""" +from edx_toggles.toggles import LegacyWaffleSwitchNamespace + +#: Namespace for use by course apps for creating availability toggles +COURSE_APPS_WAFFLE_NAMESPACE = LegacyWaffleSwitchNamespace("course_apps") diff --git a/openedx/core/djangoapps/discussions/plugins.py b/openedx/core/djangoapps/discussions/plugins.py new file mode 100644 index 0000000000..a1fe037e1d --- /dev/null +++ b/openedx/core/djangoapps/discussions/plugins.py @@ -0,0 +1,61 @@ +""" +Course app configuration for discussions. +""" +from typing import Dict, Optional + +from django.contrib.auth import get_user_model +from django.utils.translation import ugettext_noop as _ +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.course_apps.plugins import CourseApp +from .models import DiscussionsConfiguration + +User = get_user_model() + + +class DiscussionCourseApp(CourseApp): + """ + Course App config for Discussions. + """ + + app_id = "discussion" + name = _("Discussion") + description = _("Encourage participation and engagement in your course with discussion forums.") + + @classmethod + def is_available(cls, course_key: CourseKey) -> bool: + """ + Discussions is always available. + """ + return True + + @classmethod + def is_enabled(cls, course_key: CourseKey) -> bool: + """ + Discussions enable/disable status is stored in a separate model. + """ + return DiscussionsConfiguration.is_enabled(course_key) + + @classmethod + def set_enabled(cls, course_key: CourseKey, enabled: bool, user: 'User') -> bool: + """ + Set discussion enabled status in DiscussionsConfiguration model. + """ + configuration = DiscussionsConfiguration.get(course_key) + if configuration.pk is None: + raise ValueError("Can't enable/disable discussions for course before they are configured.") + configuration.enabled = enabled + configuration.save() + return configuration.enabled + + @classmethod + def get_allowed_operations(cls, course_key: CourseKey, user: Optional[User] = None) -> Dict[str, bool]: + """ + Return allowed operations for discussions app. + """ + # Can only enable discussions for a course if discussions are configured. + can_enable = DiscussionsConfiguration.get(course_key).pk is not None + return { + "enable": can_enable, + "configure": True, + } diff --git a/openedx/core/djangoapps/discussions/views.py b/openedx/core/djangoapps/discussions/views.py index 0e75579def..bf1c0a28fc 100644 --- a/openedx/core/djangoapps/discussions/views.py +++ b/openedx/core/djangoapps/discussions/views.py @@ -3,16 +3,13 @@ Handle view-logic for the djangoapp """ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey -from rest_framework import serializers from rest_framework.permissions import BasePermission from rest_framework.response import Response from rest_framework.views import APIView from common.djangoapps.student.roles import CourseStaffRole from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser - +from openedx.core.lib.api.view_utils import validate_course_key from .models import DiscussionsConfiguration from .serializers import DiscussionsConfigurationSerializer @@ -33,7 +30,7 @@ class IsStaff(BasePermission): if user.is_staff: return True course_key_string = view.kwargs.get('course_key_string') - course_key = _validate_course_key(course_key_string) + course_key = validate_course_key(course_key_string) return CourseStaffRole( course_key, ).has_user(request.user) @@ -55,7 +52,7 @@ class DiscussionsConfigurationView(APIView): """ Handle HTTP/GET requests """ - course_key = _validate_course_key(course_key_string) + course_key = validate_course_key(course_key_string) configuration = DiscussionsConfiguration.get(course_key) serializer = DiscussionsConfigurationSerializer(configuration) return Response(serializer.data) @@ -64,7 +61,7 @@ class DiscussionsConfigurationView(APIView): """ Handle HTTP/POST requests """ - course_key = _validate_course_key(course_key_string) + course_key = validate_course_key(course_key_string) configuration = DiscussionsConfiguration.get(course_key) serializer = DiscussionsConfigurationSerializer( configuration, @@ -77,20 +74,3 @@ class DiscussionsConfigurationView(APIView): if serializer.is_valid(raise_exception=True): serializer.save() return Response(serializer.data) - - -def _validate_course_key(course_key_string: str) -> CourseKey: - """ - Validate and parse a course_key string, if supported - """ - try: - course_key = CourseKey.from_string(course_key_string) - except InvalidKeyError as error: - raise serializers.ValidationError( - f"{course_key_string} is not a valid CourseKey" - ) from error - if course_key.deprecated: - raise serializers.ValidationError( - 'Deprecated CourseKeys (Org/Course/Run) are not supported.' - ) - return course_key diff --git a/openedx/core/lib/api/tests/test_view_utils.py b/openedx/core/lib/api/tests/test_view_utils.py index be7a55ee2d..ebbd53ae68 100644 --- a/openedx/core/lib/api/tests/test_view_utils.py +++ b/openedx/core/lib/api/tests/test_view_utils.py @@ -20,7 +20,7 @@ class MockAPIView(DeveloperErrorViewMixin, APIView): Mock API view for testing. """ - @verify_course_exists + @verify_course_exists() def get(self, request, course_id): """ Mock GET handler for testing. diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 400004db8e..654b860e31 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -12,7 +12,7 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthenticat from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from rest_framework import status +from rest_framework import serializers, status from rest_framework.exceptions import APIException, ErrorDetail from rest_framework.generics import GenericAPIView from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin @@ -401,33 +401,60 @@ def get_course_key(request, course_id=None): return CourseKey.from_string(course_id) -def verify_course_exists(view_func): +def verify_course_exists(missing_course_error_message=None): """ A decorator to wrap a view function that takes `course_key` as a parameter. Raises: An API error if the `course_key` is invalid, or if no `CourseOverview` exists for the given key. """ - @wraps(view_func) - def wrapped_function(self, request, **kwargs): - """ - Wraps the given view_function. - """ - try: - course_key = get_course_key(request, kwargs.get('course_id')) - except InvalidKeyError: - raise self.api_error( # lint-amnesty, pylint: disable=raise-missing-from - status_code=status.HTTP_404_NOT_FOUND, - developer_message='The provided course key cannot be parsed.', - error_code='invalid_course_key' - ) - if not CourseOverview.course_exists(course_key): - raise self.api_error( - status_code=status.HTTP_404_NOT_FOUND, - developer_message=f"Requested grade for unknown course {str(course_key)}", - error_code='course_does_not_exist' - ) + if not missing_course_error_message: + missing_course_error_message = "Unknown course {course}" - return view_func(self, request, **kwargs) - return wrapped_function + def _verify_course_exists(view_func): + @wraps(view_func) + def wrapped_function(self, request, **kwargs): + """ + Wraps the given view_function. + """ + try: + course_key = get_course_key(request, kwargs.get('course_id')) + except InvalidKeyError as error: + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The provided course key cannot be parsed.', + error_code='invalid_course_key' + ) from error + if not CourseOverview.course_exists(course_key): + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message=missing_course_error_message.format(course=str(course_key)), + error_code='course_does_not_exist' + ) + + return view_func(self, request, **kwargs) + return wrapped_function + return _verify_course_exists + + +def validate_course_key(course_key_string: str) -> CourseKey: + """ + Validate and parse a course_key string, if supported. + + Args: + course_key_string (str): string course key to validate + + Returns: + CourseKey: validated course key + + Raises: + ValidationError: DRF Validation error in case the course key is invalid + """ + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError as error: + raise serializers.ValidationError(f"{course_key_string} is not a valid CourseKey") from error + if course_key.deprecated: + raise serializers.ValidationError("Deprecated CourseKeys (Org/Course/Run) are not supported.") + return course_key diff --git a/openedx/core/lib/teams_config.py b/openedx/core/lib/teams_config.py index 658b108a1c..724c05c883 100644 --- a/openedx/core/lib/teams_config.py +++ b/openedx/core/lib/teams_config.py @@ -39,7 +39,7 @@ class TeamsConfig: # pylint: disable=eq-without-hash """ Return user-friendly string. """ - return str(self.__unicode__()) + return "Teams configuration for {} team-sets".format(len(self.teamsets)) def __repr__(self): """ @@ -170,19 +170,11 @@ class TeamsetConfig: # pylint: disable=eq-without-hash """ self._data = data if isinstance(data, dict) else {} - def __unicode__(self): - """ - Return user-friendly string. - - TODO move this code to __str__ after Py3 upgrade. - """ - return self.name - def __str__(self): """ Return user-friendly string. """ - return str(self.__unicode__()) + return self.name def __repr__(self): """ diff --git a/setup.py b/setup.py index f38685e67b..f24bf90063 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,15 @@ setup( "textbooks = lms.djangoapps.courseware.tabs:TextbookTabs", "wiki = lms.djangoapps.course_wiki.tab:WikiTab", ], + "openedx.course_app": [ + "calculator = lms.djangoapps.courseware.plugins:CalculatorCourseApp", + "discussion = openedx.core.djangoapps.discussions.plugins:DiscussionCourseApp", + "edxnotes = lms.djangoapps.edxnotes.plugins:EdxNotesCourseApp", + "progress = lms.djangoapps.courseware.plugins:ProgressCourseApp", + "teams = lms.djangoapps.teams.plugins:TeamsCourseApp", + "textbooks = lms.djangoapps.courseware.plugins:TextbooksCourseApp", + "wiki = lms.djangoapps.course_wiki.plugins:WikiCourseApp", + ], "openedx.course_tool": [ "calendar_sync_toggle = openedx.features.calendar_sync.plugins:CalendarSyncToggleTool", "course_bookmarks = openedx.features.course_bookmarks.plugins:CourseBookmarksTool", @@ -92,6 +101,7 @@ setup( "user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig", "program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig", "courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig", + "course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig", ], "cms.djangoapp": [ "announcements = openedx.features.announcements.apps:AnnouncementsConfig", @@ -111,6 +121,7 @@ setup( "password_policy = openedx.core.djangoapps.password_policy.apps:PasswordPolicyConfig", "user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig", "instructor = lms.djangoapps.instructor.apps:InstructorConfig", + "course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig", ], 'openedx.learning_context': [ 'lib = openedx.core.djangoapps.content_libraries.library_context:LibraryContextImpl',