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',