diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py index 27e9d89bb44ed26ce2dc69161b65c03772854fe0..ec7373edc004b670bab6465405d9ade33ab3549d 100644 --- a/lms/djangoapps/ccx/tests/test_views.py +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -1156,9 +1156,7 @@ class CCXCoachTabTestCase(CcxTestCase): def check_ccx_tab(self, course, user): """Helper function for verifying the ccx tab.""" - request = RequestFactory().request() - request.user = user - all_tabs = get_course_tab_list(request, course) + all_tabs = get_course_tab_list(user, course) return any(tab.type == 'ccx_coach' for tab in all_tabs) @ddt.data( diff --git a/lms/djangoapps/course_api/api.py b/lms/djangoapps/course_api/api.py index 37406e2033eaf9b1b4a376b3212164cbfc3acaad..f86f6f5e0a1384049f2975ae8626637df151a451 100644 --- a/lms/djangoapps/course_api/api.py +++ b/lms/djangoapps/course_api/api.py @@ -65,11 +65,13 @@ def course_detail(request, username, course_key): `CourseOverview` object representing the requested course """ user = get_effective_user(request.user, username) - return get_course_overview_with_access( + overview = get_course_overview_with_access( user, get_permission_for_course_about(), course_key, ) + overview.effective_user = user + return overview def _filter_by_role(course_queryset, user, roles): diff --git a/lms/djangoapps/course_api/views.py b/lms/djangoapps/course_api/views.py index 762727dd02986b6244eb328236ea28a357512864..7320fea86e862b52caf4728e38193f92db800a74 100644 --- a/lms/djangoapps/course_api/views.py +++ b/lms/djangoapps/course_api/views.py @@ -5,6 +5,7 @@ Course API Views from django.core.exceptions import ValidationError from edx_django_utils.monitoring import set_custom_metric + from edx_rest_framework_extensions.paginators import NamespacedPageNumberPagination from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.throttling import UserRateThrottle diff --git a/lms/djangoapps/course_wiki/tests/test_tab.py b/lms/djangoapps/course_wiki/tests/test_tab.py index 1ece82e07e8b2561aa37cad3437dc47c9818fcc3..4fb9b12e29f429cfa99e144c71dd7ab5552d9839 100644 --- a/lms/djangoapps/course_wiki/tests/test_tab.py +++ b/lms/djangoapps/course_wiki/tests/test_tab.py @@ -24,8 +24,7 @@ class WikiTabTestCase(ModuleStoreTestCase): def get_wiki_tab(self, user, course): """Returns true if the "Wiki" tab is shown.""" request = RequestFactory().request() - request.user = user - all_tabs = get_course_tab_list(request, course) + all_tabs = get_course_tab_list(user, course) wiki_tabs = [tab for tab in all_tabs if tab.name == 'Wiki'] return wiki_tabs[0] if len(wiki_tabs) == 1 else None diff --git a/lms/djangoapps/courseware/entrance_exams.py b/lms/djangoapps/courseware/entrance_exams.py index 381ab1708d06bdcb6a9e5d1d841079ae6b352959..0f87090517a7a0a2b6d86cf4579cd5923be7848a 100644 --- a/lms/djangoapps/courseware/entrance_exams.py +++ b/lms/djangoapps/courseware/entrance_exams.py @@ -17,7 +17,8 @@ def course_has_entrance_exam(course): """ if not is_entrance_exams_enabled(): return False - if not course.entrance_exam_enabled: + entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', None) + if not entrance_exam_enabled: return False if not course.entrance_exam_id: return False diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 4d8704642a9e3d7b2bab2c9747305239feeaf303..0d075e53256d10825c174c6d408fa36ed70859bd 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -307,11 +307,10 @@ class SingleTextbookTab(CourseTab): raise NotImplementedError('SingleTextbookTab should not be serialized.') -def get_course_tab_list(request, course): +def get_course_tab_list(user, course): """ Retrieves the course tab list from xmodule.tabs and manipulates the set as necessary """ - user = request.user xmodule_tab_list = CourseTabList.iterate_displayable(course, user=user) # Now that we've loaded the tabs for this course, perform the Entrance Exam work. diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 16e28c820bc58403863c1269b0ac89293cb4068e..0516ca00cc1b75030fff2a44d6e828744e4dab65 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -385,7 +385,6 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi 'description': 'Testing Courseware Tabs' } self.user.is_staff = False - request = get_mock_request(self.user) self.course.entrance_exam_enabled = True self.course.entrance_exam_id = six.text_type(entrance_exam.location) milestone = add_milestone(milestone) @@ -400,7 +399,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi self.relationship_types['FULFILLS'], milestone ) - course_tab_list = get_course_tab_list(request, self.course) + course_tab_list = get_course_tab_list(self.user, self.course) self.assertEqual(len(course_tab_list), 1) self.assertEqual(course_tab_list[0]['tab_id'], 'courseware') self.assertEqual(course_tab_list[0]['name'], 'Entrance Exam') @@ -425,8 +424,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi # log in again as student self.client.logout() self.login(self.email, self.password) - request = get_mock_request(self.user) - course_tab_list = get_course_tab_list(request, self.course) + course_tab_list = get_course_tab_list(self.user, self.course) self.assertEqual(len(course_tab_list), 4) def test_course_tabs_list_for_staff_members(self): @@ -438,8 +436,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi self.client.logout() staff_user = StaffFactory(course_key=self.course.id) self.client.login(username=staff_user.username, password='test') - request = get_mock_request(staff_user) - course_tab_list = get_course_tab_list(request, self.course) + course_tab_list = get_course_tab_list(staff_user, self.course) self.assertEqual(len(course_tab_list), 4) @@ -480,8 +477,7 @@ class TextBookCourseViewsTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest """ type_to_reverse_name = {'textbook': 'book', 'pdftextbook': 'pdf_book', 'htmltextbook': 'html_book'} self.addCleanup(set_current_request, None) - request = get_mock_request(self.user) - course_tab_list = get_course_tab_list(request, self.course) + course_tab_list = get_course_tab_list(self.user, self.course) num_of_textbooks_found = 0 for tab in course_tab_list: # Verify links of all textbook type tabs. @@ -706,8 +702,7 @@ class CourseTabListTestCase(TabListTestCase): user = self.create_mock_user(is_staff=False, is_enrolled=True) self.addCleanup(set_current_request, None) - request = get_mock_request(user) - course_tab_list = get_course_tab_list(request, self.course) + course_tab_list = get_course_tab_list(user, self.course) name_list = [x.name for x in course_tab_list] self.assertIn('Static Tab Free', name_list) self.assertNotIn('Static Tab Instructors Only', name_list) @@ -716,8 +711,7 @@ class CourseTabListTestCase(TabListTestCase): self.client.logout() staff_user = StaffFactory(course_key=self.course.id) self.client.login(username=staff_user.username, password='test') - request = get_mock_request(staff_user) - course_tab_list_staff = get_course_tab_list(request, self.course) + course_tab_list_staff = get_course_tab_list(staff_user, self.course) name_list_staff = [x.name for x in course_tab_list_staff] self.assertIn('Static Tab Free', name_list_staff) self.assertIn('Static Tab Instructors Only', name_list_staff) @@ -775,18 +769,17 @@ class CourseInfoTabTestCase(TabTestCase): def setUp(self): self.user = self.create_mock_user() self.addCleanup(set_current_request, None) - self.request = get_mock_request(self.user) @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=False) def test_default_tab(self): # Verify that the course info tab is the first tab - tabs = get_course_tab_list(self.request, self.course) + tabs = get_course_tab_list(self.user, self.course) self.assertEqual(tabs[0].type, 'course_info') @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True) def test_default_tab_for_new_course_experience(self): # Verify that the unified course experience hides the course info tab - tabs = get_course_tab_list(self.request, self.course) + tabs = get_course_tab_list(self.user, self.course) self.assertEqual(tabs[0].type, 'courseware') # TODO: LEARNER-611 - remove once course_info is removed. diff --git a/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py b/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py index 960f7997ffe865b51a5e6c9c8cf502d5cbd1781f..762e5d070a18cbc96818aa1abf92f7872f64b5d9 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py @@ -1248,8 +1248,7 @@ class DiscussionTabTestCase(ModuleStoreTestCase): def discussion_tab_present(self, user): """ Returns true if the user has access to the discussion tab. """ request = RequestFactory().request() - request.user = user - all_tabs = get_course_tab_list(request, self.course) + all_tabs = get_course_tab_list(user, self.course) return any(tab.type == 'discussion' for tab in all_tabs) def test_tab_access(self): diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index de11662ddde81bf88efe489095799bdf172a0c5e..8fce8bc8d767ec084b3f47b0a20618233bf55abf 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -1003,9 +1003,7 @@ class EdxNotesViewsTest(ModuleStoreTestCase): """ def has_notes_tab(user, course): """Returns true if the "Notes" tab is shown.""" - request = RequestFactory().request() - request.user = user - tabs = get_course_tab_list(request, course) + tabs = get_course_tab_list(user, course) return len([tab for tab in tabs if tab.type == 'edxnotes']) == 1 self.assertFalse(has_notes_tab(self.user, self.course)) diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py index f3ee5954e599642936c9e714b3905ee46530b242..ecf76df5ee3be8dc00b1c72908908737ebe0207c 100644 --- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py +++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py @@ -107,9 +107,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT """ def has_instructor_tab(user, course): """Returns true if the "Instructor" tab is shown.""" - request = RequestFactory().request() - request.user = user - tabs = get_course_tab_list(request, course) + tabs = get_course_tab_list(user, course) return len([tab for tab in tabs if tab.name == 'Instructor']) == 1 self.assertTrue(has_instructor_tab(self.instructor, self.course)) diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html index 5946d7319b3c6c800910670e5b33a504d2de9c94..16257629c7d61636fb1859750431a25c3611b60c 100644 --- a/lms/templates/courseware/course_navigation.html +++ b/lms/templates/courseware/course_navigation.html @@ -35,7 +35,7 @@ if course is not None: % if disable_tabs is UNDEFINED or not disable_tabs: <% - tab_list = get_course_tab_list(request, course) + tab_list = get_course_tab_list(request.user, course) %> % if uses_bootstrap: <nav class="navbar course-tabs pb-0 navbar-expand" aria-label="${_('Course')}"> diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0019_improve_courseoverviewtab.py b/openedx/core/djangoapps/content/course_overviews/migrations/0019_improve_courseoverviewtab.py new file mode 100644 index 0000000000000000000000000000000000000000..70b729cc15af3b4dda9ab51f9401bfaaf0ff3921 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0019_improve_courseoverviewtab.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.27 on 2020-01-16 17:23 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0018_add_start_end_in_CourseOverview'), + ] + + operations = [ + migrations.AddField( + model_name='courseoverviewtab', + name='course_staff_only', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='courseoverviewtab', + name='name', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='courseoverviewtab', + name='type', + field=models.CharField(max_length=50, null=True), + ), + migrations.AlterField( + model_name='courseoverviewtab', + name='course_overview', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tab_set', to='course_overviews.CourseOverview'), + ), + ] diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index a621056a0593aedb6510d5daee0780f5468ac82c..0453dd539ccde586a5bcf420248b3d4a4854e147 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -16,6 +16,7 @@ from django.db.models.fields import BooleanField, DateTimeField, DecimalField, F from django.db.utils import IntegrityError from django.template import defaultfilters from django.utils.encoding import python_2_unicode_compatible +from django.utils.functional import cached_property from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField from six import text_type # pylint: disable=ungrouped-imports @@ -31,6 +32,8 @@ from xmodule import block_metadata_utils, course_metadata_utils from xmodule.course_module import DEFAULT_START_DATE, CourseDescriptor from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore +from xmodule.tabs import CourseTab + log = logging.getLogger(__name__) @@ -54,7 +57,7 @@ class CourseOverview(TimeStampedModel): app_label = 'course_overviews' # IMPORTANT: Bump this whenever you modify this model and/or add a migration. - VERSION = 7 + VERSION = 8 # Cache entry versioning. version = IntegerField() @@ -247,7 +250,12 @@ class CourseOverview(TimeStampedModel): # Remove and recreate all the course tabs CourseOverviewTab.objects.filter(course_overview=course_overview).delete() CourseOverviewTab.objects.bulk_create([ - CourseOverviewTab(tab_id=tab.tab_id, course_overview=course_overview) + CourseOverviewTab( + tab_id=tab.tab_id, + type=tab.type, + name=tab.name, + course_staff_only=tab.course_staff_only, + course_overview=course_overview) for tab in course.tabs ]) # Remove and recreate course images @@ -629,13 +637,25 @@ class CourseOverview(TimeStampedModel): """ Returns True if course has discussion tab and is enabled """ - tabs = self.tabs.all() + tabs = self.tab_set.all() # creates circular import; hence explicitly referenced is_discussion_enabled for tab in tabs: if tab.tab_id == "discussion" and django_comment_client.utils.is_discussion_enabled(self.id): return True return False + @property + def tabs(self): + """ + Returns an iterator of CourseTabs. + """ + for tab_dict in self.tab_set.all().values(): + tab = CourseTab.from_json(tab_dict) + if tab is None: + log.warning("Can't instantiate CourseTab from %r", tab_dict) + else: + yield tab + @property def image_urls(self): """ @@ -733,6 +753,49 @@ class CourseOverview(TimeStampedModel): return urlunparse(('', base_url, path, params, query, fragment)) + @cached_property + def _original_course(self): + """ + Returns the course from the modulestore. + """ + log.warning('Falling back on modulestore to get course information for %s', self.id) + return modulestore().get_course(self.id) + + @property + def allow_public_wiki_access(self): + """ + TODO: move this to the model. + """ + return self._original_course.allow_public_wiki_access + + @property + def textbooks(self): + """ + TODO: move this to the model. + """ + return self._original_course.textbooks + + @property + def hide_progress_tab(self): + """ + TODO: move this to the model. + """ + return self._original_course.hide_progress_tab + + @property + def edxnotes(self): + """ + TODO: move this to the model. + """ + return self._original_course.edxnotes + + @property + def enable_ccx(self): + """ + TODO: move this to the model. + """ + return self._original_course.enable_ccx + def __str__(self): """Represent ourselves with the course key.""" return six.text_type(self.id) @@ -745,7 +808,13 @@ class CourseOverviewTab(models.Model): .. no_pii: """ tab_id = models.CharField(max_length=50) - course_overview = models.ForeignKey(CourseOverview, db_index=True, related_name="tabs", on_delete=models.CASCADE) + course_overview = models.ForeignKey(CourseOverview, db_index=True, related_name="tab_set", on_delete=models.CASCADE) + type = models.CharField(max_length=50, null=True) + name = models.TextField(null=True) + course_staff_only = models.BooleanField(default=False) + + def __str__(self): + return self.tab_id @python_2_unicode_compatible diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py index 3b11a36be7f905d3332a5405648940a7ae93ff34..05bb64cb2243ab876af15f0fb6e46061c2f3f27e 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py @@ -202,7 +202,7 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase, Cache # test tabs for both cached miss and cached hit courses for course_overview in [course_overview_cache_miss, course_overview_cache_hit]: - course_overview_tabs = course_overview.tabs.all() + course_overview_tabs = course_overview.tab_set.all() course_resp_tabs = {tab.tab_id for tab in course_overview_tabs} self.assertEqual(self.COURSE_OVERVIEW_TABS, course_resp_tabs) @@ -1089,7 +1089,7 @@ class CourseOverviewTabTestCase(ModuleStoreTestCase): """ course = CourseFactory.create(default_store=modulestore_type) course_overview = CourseOverview.get_from_id(course.id) - expected_tabs = {tab.tab_id for tab in course_overview.tabs.all()} + expected_tabs = {tab.tab_id for tab in course_overview.tab_set.all()} with mock.patch( 'openedx.core.djangoapps.content.course_overviews.models.CourseOverviewTab.objects.bulk_create' @@ -1105,6 +1105,6 @@ class CourseOverviewTabTestCase(ModuleStoreTestCase): # Asserts that the tabs deletion is properly rolled back to a save point and # the course overview is not updated. - actual_tabs = {tab.tab_id for tab in course_overview.tabs.all()} + actual_tabs = {tab.tab_id for tab in course_overview.tab_set.all()} self.assertEqual(actual_tabs, expected_tabs) self.assertNotEqual(course_overview.display_name, course.display_name) diff --git a/openedx/core/djangoapps/courseware_api/__init__.py b/openedx/core/djangoapps/courseware_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openedx/core/djangoapps/courseware_api/apps.py b/openedx/core/djangoapps/courseware_api/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..1ad0dcebdc9055c205d1e43f5cd2bd69a1676021 --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/apps.py @@ -0,0 +1,26 @@ +""" +Courseware API Application Configuration + +Signal handlers are connected here. +""" + + +from django.apps import AppConfig + +from openedx.core.djangoapps.plugins.constants import PluginURLs, ProjectType + + +class CoursewareAPIConfig(AppConfig): + """ + AppConfig for courseware API app + """ + name = 'openedx.core.djangoapps.courseware_api' + plugin_app = { + PluginURLs.CONFIG: { + ProjectType.LMS: { + PluginURLs.NAMESPACE: 'courseware_api', + PluginURLs.REGEX: 'api/courseware/', + PluginURLs.RELATIVE_PATH: 'urls', + } + }, + } diff --git a/openedx/core/djangoapps/courseware_api/docs/decisions/0001-record-architecture-decisions.rst b/openedx/core/djangoapps/courseware_api/docs/decisions/0001-record-architecture-decisions.rst new file mode 100644 index 0000000000000000000000000000000000000000..0ab9ab44e6cf049643c536c99f09d0fcd12f7f59 --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/docs/decisions/0001-record-architecture-decisions.rst @@ -0,0 +1,32 @@ +1. Record Architecture Decisions +-------------------------------- + +Status +------ + +Accepted + +Context +------- + +We would like to keep a historical record on the architectural +decisions we make with this app as it evolves over time. + +Decision +-------- + +We will use Architecture Decision Records, as described by +Michael Nygard in `Documenting Architecture Decisions`_ + +.. _Documenting Architecture Decisions: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions + +Consequences +------------ + +See Michael Nygard's article, linked above. + +References +---------- + +* https://resources.sei.cmu.edu/asset_files/Presentation/2017_017_001_497746.pdf +* https://github.com/npryce/adr-tools/tree/master/doc/adr diff --git a/openedx/core/djangoapps/courseware_api/serializers.py b/openedx/core/djangoapps/courseware_api/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..ea983641ab87609f2617484696911f1cab70e4ad --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/serializers.py @@ -0,0 +1,120 @@ +""" +Course API Serializers. Representing course catalog data +""" + +from django.urls import reverse +from rest_framework import serializers + +from lms.djangoapps.courseware.tabs import get_course_tab_list +from openedx.core.lib.api.fields import AbsoluteURLField +from student.models import CourseEnrollment + + +class _MediaSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Nested serializer to represent a media object. + """ + + def __init__(self, uri_attribute, *args, **kwargs): + super(_MediaSerializer, self).__init__(*args, **kwargs) + self.uri_attribute = uri_attribute + + uri = serializers.SerializerMethodField(source='*') + + class Meta: + ref_name = 'courseware_api' + + def get_uri(self, course_overview): + """ + Get the representation for the media resource's URI + """ + return getattr(course_overview, self.uri_attribute) + + +class ImageSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Collection of URLs pointing to images of various sizes. + + The URLs will be absolute URLs with the host set to the host of the current request. If the values to be + serialized are already absolute URLs, they will be unchanged. + """ + raw = AbsoluteURLField() + small = AbsoluteURLField() + large = AbsoluteURLField() + + class Meta: + ref_name = 'courseware_api' + + +class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Nested serializer to represent a collection of media objects + """ + course_image = _MediaSerializer(source='*', uri_attribute='course_image_url') + course_video = _MediaSerializer(source='*', uri_attribute='course_video_url') + image = ImageSerializer(source='image_urls') + + class Meta: + ref_name = 'courseware_api' + + +class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer for Course objects providing minimal data about the course. + Compare this with CourseDetailSerializer. + """ + + effort = serializers.CharField() + end = serializers.DateTimeField() + enrollment_start = serializers.DateTimeField() + enrollment_end = serializers.DateTimeField() + id = serializers.CharField() # pylint: disable=invalid-name + media = _CourseApiMediaCollectionSerializer(source='*') + name = serializers.CharField(source='display_name_with_default_escaped') + number = serializers.CharField(source='display_number_with_default') + org = serializers.CharField(source='display_org_with_default') + short_description = serializers.CharField() + start = serializers.DateTimeField() + start_display = serializers.CharField() + start_type = serializers.CharField() + pacing = serializers.CharField() + enrollment = serializers.SerializerMethodField() + tabs = serializers.SerializerMethodField() + + def __init__(self, *args, **kwargs): + """ + Initialize the serializer. + If `requested_fields` is set, then only return that subset of fields. + """ + super().__init__(*args, **kwargs) + requested_fields = self.context['requested_fields'] + if requested_fields is not None: + allowed = set(requested_fields.split(',')) + existing = set(self.fields) + for field_name in existing - allowed: + self.fields.pop(field_name) + + def get_tabs(self, course_overview): + """ + Return course tab metadata. + """ + tabs = [] + for priority, tab in enumerate(get_course_tab_list(course_overview.effective_user, course_overview)): + tabs.append({ + 'title': tab.title, + 'slug': tab.tab_id, + 'priority': priority, + 'type': tab.type, + 'url': tab.link_func(course_overview, reverse), + }) + return tabs + + def get_enrollment(self, course_overview): + """ + Return the enrollment for the logged in user. + """ + mode, is_active = CourseEnrollment.enrollment_mode_for_user( + course_overview.effective_user, + course_overview.id + ) + return {'mode': mode, 'is_active': is_active} diff --git a/openedx/core/djangoapps/courseware_api/tests/__init__.py b/openedx/core/djangoapps/courseware_api/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..4a93e52ff65f8bd7acf3aa73aba05cd898108a43 --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -0,0 +1,108 @@ +""" +Tests for courseware API +""" +from datetime import datetime +import unittest +import ddt + +from django.conf import settings + +from xmodule.modulestore.django import modulestore + +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + SharedModuleStoreTestCase +) +from xmodule.modulestore.tests.factories import ItemFactory, ToyCourseFactory +from student.tests.factories import UserFactory +from student.models import CourseEnrollment + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class BaseCoursewareTests(SharedModuleStoreTestCase): + """ + Base class for courseware API tests + """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.store = modulestore() + cls.course = ToyCourseFactory.create( + end=datetime(2028, 1, 1, 1, 1, 1), + enrollment_start=datetime(2020, 1, 1, 1, 1, 1), + enrollment_end=datetime(2028, 1, 1, 1, 1, 1), + emit_signals=True, + modulestore=cls.store, + ) + cls.user = UserFactory( + username='student', + email=u'user@example.com', + password='foo', + is_staff=False + ) + cls.url = '/api/courseware/course/{}'.format(cls.course.id) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.store.delete_course(cls.course.id, cls.user.id) + + def setUp(self): + super().setUp() + self.client.login(username=self.user.username, password='foo') + + def test_unauth(self): + self.client.logout() + response = self.client.get(self.url) + assert response.status_code == 401 + + +# pylint: disable=test-inherits-tests +@ddt.ddt +class CourseApiTestViews(BaseCoursewareTests): + """ + Tests for the courseware REST API + """ + @ddt.data((None,), ('audit',), ('verified',)) + @ddt.unpack + def test_course_metadata(self, enrollment_mode): + if enrollment_mode: + CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode) + response = self.client.get(self.url) + assert response.status_code == 200 + enrollment = response.data['enrollment'] + if enrollment_mode: + assert enrollment_mode == enrollment['mode'] + assert enrollment['is_active'] + assert len(response.data['tabs']) == 4 + else: + assert len(response.data['tabs']) == 2 + assert not enrollment['is_active'] + + +# pylint: disable=test-inherits-tests +class SequenceApiTestViews(BaseCoursewareTests): + """ + Tests for the sequence REST API + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + chapter = ItemFactory(parent=cls.course, category='chapter') + cls.sequence = ItemFactory(parent=chapter, category='sequential', display_name='sequence') + ItemFactory.create(parent=cls.sequence, category='vertical', display_name="Vertical") + cls.url = '/api/courseware/sequence/{}'.format(cls.sequence.location) + + @classmethod + def tearDownClass(cls): + cls.store.delete_item(cls.sequence.location, cls.user.id) + super().tearDownClass() + + def test_sequence_metadata(self): + print(self.url) + print(self.course.location) + response = self.client.get(self.url) + assert response.status_code == 200 + assert response.data['display_name'] == 'sequence' + assert len(response.data['items']) == 1 diff --git a/openedx/core/djangoapps/courseware_api/urls.py b/openedx/core/djangoapps/courseware_api/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..865955a6f1f72c929a9d94fc0afb07700b1b26a4 --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/urls.py @@ -0,0 +1,18 @@ +""" +Contains all the URLs +""" + + +from django.conf import settings +from django.conf.urls import url + +from openedx.core.djangoapps.courseware_api import views + +urlpatterns = [ + url(r'^course/{}'.format(settings.COURSE_KEY_PATTERN), + views.CoursewareInformation.as_view(), + name="courseware-api"), + url(r'^sequence/{}'.format(settings.USAGE_KEY_PATTERN), + views.SequenceMetadata.as_view(), + name="sequence-api"), +] diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py new file mode 100644 index 0000000000000000000000000000000000000000..c842f1be0cee5bca05e69a852c3544e1c44e7c6a --- /dev/null +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -0,0 +1,133 @@ +""" +Course API Views +""" + +import json + +from opaque_keys.edx.keys import CourseKey, UsageKey +from rest_framework.generics import RetrieveAPIView +from rest_framework.response import Response +from rest_framework.views import APIView + +from lms.djangoapps.course_api.api import course_detail +from lms.djangoapps.courseware.module_render import get_module_by_usage_id +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes + +from .serializers import CourseInfoSerializer + + +@view_auth_classes(is_authenticated=True) +class CoursewareInformation(DeveloperErrorViewMixin, RetrieveAPIView): + """ + **Use Cases** + + Request details for a course + + **Example Requests** + + GET /api/courseware/course/{course_key} + + **Response Values** + + Body consists of the following fields: + + * effort: A textual description of the weekly hours of effort expected + in the course. + * end: Date the course ends, in ISO 8601 notation + * enrollment_end: Date enrollment ends, in ISO 8601 notation + * enrollment_start: Date enrollment begins, in ISO 8601 notation + * id: A unique identifier of the course; a serialized representation + of the opaque key identifying the course. + * media: An object that contains named media items. Included here: + * course_image: An image to show for the course. Represented + as an object with the following fields: + * uri: The location of the image + * name: Name of the course + * number: Catalog number of the course + * org: Name of the organization that owns the course + * short_description: A textual description of the course + * start: Date the course begins, in ISO 8601 notation + * start_display: Readably formatted start of the course + * start_type: Hint describing how `start_display` is set. One of: + * `"string"`: manually set by the course author + * `"timestamp"`: generated from the `start` timestamp + * `"empty"`: no start date is specified + * pacing: Course pacing. Possible values: instructor, self + * tabs: Course tabs + * enrollment: Enrollment status of authenticated user + * mode: `audit`, `verified`, etc + * is_active: boolean + + **Parameters:** + + requested_fields (optional) comma separated list: + If set, then only those fields will be returned. + + **Returns** + + * 200 on success with above fields. + * 400 if an invalid parameter was sent or the username was not provided + for an authenticated request. + * 403 if a user who does not have permission to masquerade as + another user specifies a username other than their own. + * 404 if the course is not available or cannot be seen. + """ + + serializer_class = CourseInfoSerializer + + def get_object(self): + """ + Return the requested course object, if the user has appropriate + permissions. + """ + return course_detail( + self.request, + self.request.user.username, + CourseKey.from_string(self.kwargs['course_key_string']), + ) + + def get_serializer_context(self): + """ + Return extra context to be used by the serializer class. + """ + context = super().get_serializer_context() + context['requested_fields'] = self.request.GET.get('requested_fields', None) + return context + + +@view_auth_classes(is_authenticated=True) +class SequenceMetadata(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + + Request details for a sequence/subsection + + **Example Requests** + + GET /api/courseware/sequence/{usage_key} + + **Response Values** + + Body consists of the following fields: + TODO + + **Returns** + + * 200 on success with above fields. + * 400 if an invalid parameter was sent. + * 403 if a user who does not have permission to masquerade as + another user specifies a username other than their own. + * 404 if the course is not available or cannot be seen. + """ + def get(self, request, usage_key_string, *args, **kwargs): # pylint: disable=unused-argument + """ + Return response to a GET request. + """ + usage_key = UsageKey.from_string(usage_key_string) + + sequence, _ = get_module_by_usage_id( + self.request, + str(usage_key.course_key), + str(usage_key), + disable_staff_debug_info=True) + return Response(json.loads(sequence.handle_ajax('metadata', None))) diff --git a/setup.py b/setup.py index f1dff5cec670a2c9ed1c4505744cbf36f3c4e635..3a07a12e21cc07bc3573fba1a1d44ebb826b6f96 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,7 @@ setup( "password_policy = openedx.core.djangoapps.password_policy.apps:PasswordPolicyConfig", "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", ], "cms.djangoapp": [ "announcements = openedx.features.announcements.apps:AnnouncementsConfig",