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
This commit is contained in:
Kshitij Sobti
2021-06-23 22:21:12 +05:30
committed by GitHub
parent 29fcf8b868
commit d2c2fcdefe
32 changed files with 1157 additions and 118 deletions

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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):

View File

@@ -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.

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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",
}
},
}

View File

@@ -0,0 +1,3 @@
"""
Models for course apps.
"""

View File

@@ -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

View File

@@ -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"]

View File

@@ -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")),
]

View File

@@ -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"),
]

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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):
"""

View File

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