diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0022_courseoverviewtab_is_hidden.py b/openedx/core/djangoapps/content/course_overviews/migrations/0022_courseoverviewtab_is_hidden.py new file mode 100644 index 0000000000..a6ee3d5693 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0022_courseoverviewtab_is_hidden.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.12 on 2020-04-30 15:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0021_courseoverviewtab_link'), + ] + + operations = [ + migrations.AddField( + model_name='courseoverviewtab', + name='is_hidden', + field=models.BooleanField(default=False), + ), + ] diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index 4bf77d1931..2ac48e523b 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -59,7 +59,7 @@ class CourseOverview(TimeStampedModel): app_label = 'course_overviews' # IMPORTANT: Bump this whenever you modify this model and/or add a migration. - VERSION = 10 + VERSION = 11 # this one goes to eleven # Cache entry versioning. version = IntegerField() @@ -262,6 +262,7 @@ class CourseOverview(TimeStampedModel): course_staff_only=tab.course_staff_only, url_slug=tab.get('url_slug'), link=tab.get('link'), + is_hidden=tab.get('is_hidden', False), course_overview=course_overview) for tab in course.tabs ]) @@ -864,6 +865,7 @@ class CourseOverviewTab(models.Model): course_staff_only = models.BooleanField(default=False) url_slug = models.TextField(null=True) link = models.TextField(null=True) + is_hidden = models.BooleanField(default=False) def __str__(self): return self.tab_id diff --git a/openedx/core/djangoapps/courseware_api/serializers.py b/openedx/core/djangoapps/courseware_api/serializers.py index 060abad700..82fa31e373 100644 --- a/openedx/core/djangoapps/courseware_api/serializers.py +++ b/openedx/core/djangoapps/courseware_api/serializers.py @@ -2,16 +2,8 @@ Course API Serializers. Representing course catalog data """ -from babel.numbers import get_currency_symbol - -from django.urls import reverse from rest_framework import serializers -from course_modes.models import CourseMode -from edxnotes.helpers import is_feature_enabled -from lms.djangoapps.courseware.tabs import get_course_tab_list -from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link - from openedx.core.lib.api.fields import AbsoluteURLField @@ -87,12 +79,12 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract- start_type = serializers.CharField() pacing = serializers.CharField() enrollment = serializers.DictField() - tabs = serializers.SerializerMethodField() - verified_mode = serializers.SerializerMethodField() + tabs = serializers.ListField() + verified_mode = serializers.DictField() show_calculator = serializers.BooleanField() is_staff = serializers.BooleanField() can_load_courseware = serializers.DictField() - notes = serializers.SerializerMethodField() + notes = serializers.DictField() def __init__(self, *args, **kwargs): """ @@ -106,41 +98,3 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract- 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 or tab.get('name', ''), - 'slug': tab.tab_id, - 'priority': priority, - 'type': tab.type, - 'url': tab.link_func(course_overview, reverse), - }) - return tabs - - def get_verified_mode(self, course_overview): - """ - Return verified mode information, or None. - """ - mode = CourseMode.verified_mode_for_course(course_overview.id) - if mode: - return { - 'price': mode.min_price, - 'currency': mode.currency.upper(), - 'currency_symbol': get_currency_symbol(mode.currency.upper()), - 'sku': mode.sku, - 'upgrade_url': verified_upgrade_deadline_link(course_overview.effective_user, course_overview), - } - - def get_notes(self, course_overview): - """ - Return whether edxnotes is enabled and visible. - """ - return { - 'enabled': is_feature_enabled(course_overview, course_overview.effective_user), - 'visible': course_overview.edxnotes_visibility, - } diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index 23b01b48cf..3ee764673a 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -1,27 +1,20 @@ """ Tests for courseware API """ -from datetime import datetime import unittest +from datetime import datetime + import ddt import mock - from django.conf import settings -from lms.djangoapps.courseware.access_utils import ( - ACCESS_DENIED, - ACCESS_GRANTED -) +from lms.djangoapps.courseware.access_utils import ACCESS_DENIED, ACCESS_GRANTED from lms.djangoapps.courseware.tabs import ExternalLinkCourseTab -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 +from student.tests.factories import UserFactory +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 @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -69,6 +62,9 @@ class CourseApiTestViews(BaseCoursewareTests): def setUpClass(cls): BaseCoursewareTests.setUpClass() cls.course.tabs.append(ExternalLinkCourseTab.load('external_link', name='Zombo', link='http://zombo.com')) + cls.course.tabs.append( + ExternalLinkCourseTab.load('external_link', name='Hidden', link='http://hidden.com', is_hidden=True) + ) cls.store.update_item(cls.course, cls.user.id) @ddt.data( @@ -96,11 +92,11 @@ class CourseApiTestViews(BaseCoursewareTests): assert len(response.data['tabs']) == 5 found = False for tab in response.data['tabs']: - if tab['type'] == 'external_link' and tab['url'] == 'http://zombo.com': - found = True - break - else: - assert found, 'external link not in course tabs' + if tab['type'] == 'external_link': + assert tab['url'] != 'http://hidden.com', "Hidden tab is not hidden" + if tab['url'] == 'http://zombo.com': + found = True + assert found, 'external link not in course tabs' elif enable_anonymous and not logged_in: # multiple checks use this handler check_public_access.assert_called() diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 19ac4639b3..cac8b54bd4 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -4,26 +4,135 @@ Course API Views import json +from babel.numbers import get_currency_symbol +from django.urls import reverse 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 course_modes.models import CourseMode +from edxnotes.helpers import is_feature_enabled from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.courses import check_course_access from lms.djangoapps.courseware.module_render import get_module_by_usage_id -from student.models import CourseEnrollment - +from lms.djangoapps.courseware.tabs import get_course_tab_list +from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.access import generate_course_expired_message from openedx.features.discounts.utils import generate_offer_html -from xmodule.course_module import COURSE_VISIBILITY_PUBLIC +from student.models import CourseEnrollment from .serializers import CourseInfoSerializer +class CoursewareMeta: + """ + Encapsulates courseware and enrollment metadata. + """ + def __init__(self, course_key, request, username=''): + self.overview = course_detail( + request, + username or request.user.username, + course_key, + ) + self.effective_user = self.overview.effective_user + self.course_key = course_key + + def __getattr__(self, name): + return getattr(self.overview, name) + + @property + def is_staff(self): + return has_access(self.effective_user, 'staff', self.overview).has_access + + @property + def enrollment(self): + """ + Return enrollment information. + """ + if self.effective_user.is_anonymous: + mode = None + is_active = False + else: + mode, is_active = CourseEnrollment.enrollment_mode_for_user( + self.effective_user, + self.course_key + ) + return {'mode': mode, 'is_active': is_active} + + @property + def course_expired_message(self): + # TODO: TNL-7185 Legacy: Refactor to return the expiration date and format the message in the MFE + return generate_course_expired_message(self.effective_user, self.overview) + + @property + def offer_html(self): + # TODO: TNL-7185 Legacy: Refactor to return the offer data and format the message in the MFE + return generate_offer_html(self.effective_user, self.overview) + + @property + def content_type_gating_enabled(self): + return ContentTypeGatingConfig.enabled_for_enrollment( + user=self.effective_user, + course_key=self.course_key, + ) + + @property + def can_load_courseware(self): + return check_course_access( + self.overview, + self.effective_user, + 'load', + check_if_enrolled=True, + check_survey_complete=False, + check_if_authenticated=True, + ).to_json() + + @property + def tabs(self): + """ + Return course tab metadata. + """ + tabs = [] + for priority, tab in enumerate(get_course_tab_list(self.effective_user, self.overview)): + tabs.append({ + 'title': tab.title or tab.get('name', ''), + 'slug': tab.tab_id, + 'priority': priority, + 'type': tab.type, + 'url': tab.link_func(self.overview, reverse), + }) + return tabs + + @property + def verified_mode(self): + """ + Return verified mode information, or None. + """ + mode = CourseMode.verified_mode_for_course(self.course_key) + if mode: + return { + 'price': mode.min_price, + 'currency': mode.currency.upper(), + 'currency_symbol': get_currency_symbol(mode.currency.upper()), + 'sku': mode.sku, + 'upgrade_url': verified_upgrade_deadline_link(self.effective_user, self.overview), + } + + @property + def notes(self): + """ + Return whether edxnotes is enabled and visible. + """ + return { + 'enabled': is_feature_enabled(self.overview, self.effective_user), + 'visible': self.overview.edxnotes_visibility, + } + + class CoursewareInformation(RetrieveAPIView): """ **Use Cases** @@ -71,6 +180,7 @@ class CoursewareInformation(RetrieveAPIView): requested_fields (optional) comma separated list: If set, then only those fields will be returned. + username (optional) username to masquerade as (if requesting user is staff) **Returns** @@ -89,43 +199,16 @@ class CoursewareInformation(RetrieveAPIView): Return the requested course object, if the user has appropriate permissions. """ - - overview = course_detail( - self.request, - self.request.user.username, - CourseKey.from_string(self.kwargs['course_key_string']), - ) - - if self.request.user.is_anonymous: - mode = None - is_active = False + if self.request.user.is_staff: + username = self.request.GET.get('username', '') or self.request.user.username else: - mode, is_active = CourseEnrollment.enrollment_mode_for_user( - overview.effective_user, - overview.id - ) - overview.enrollment = {'mode': mode, 'is_active': is_active} - - overview.is_staff = has_access(self.request.user, 'staff', overview).has_access - overview.can_load_courseware = check_course_access( - overview, - self.request.user, - 'load', - check_if_enrolled=True, - check_survey_complete=False, - check_if_authenticated=True, - ).to_json() - - # TODO: TNL-7185 Legacy: Refactor to return the expiration date and format the message in the MFE - overview.course_expired_message = generate_course_expired_message(self.request.user, overview) - # TODO: TNL-7185 Legacy: Refactor to return the offer data and format the message in the MFE - overview.offer_html = generate_offer_html(self.request.user, overview) - - course_key = CourseKey.from_string(self.kwargs['course_key_string']) - overview.content_type_gating_enabled = ContentTypeGatingConfig.enabled_for_enrollment( - user=self.request.user, - course_key=course_key, + username = self.request.user.username + overview = CoursewareMeta( + CourseKey.from_string(self.kwargs['course_key_string']), + self.request, + username=username, ) + return overview def get_serializer_context(self): @@ -160,7 +243,7 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView): 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 + def get(self, request, usage_key_string, *args, **kwargs): """ Return response to a GET request. """