diff --git a/lms/djangoapps/support/urls.py b/lms/djangoapps/support/urls.py index 5f3c8621b4..3679cd21c8 100644 --- a/lms/djangoapps/support/urls.py +++ b/lms/djangoapps/support/urls.py @@ -7,6 +7,7 @@ from lms.djangoapps.support.views.contact_us import ContactUsView from support.views.certificate import CertificatesSupportView from support.views.course_entitlements import EntitlementSupportView from support.views.enrollments import EnrollmentSupportListView, EnrollmentSupportView +from support.views.feature_based_enrollments import FeatureBasedEnrollmentsSupportView from support.views.index import index from support.views.manage_user import ManageUserDetailView, ManageUserSupportView from support.views.refund import RefundSupportView @@ -32,4 +33,9 @@ urlpatterns = [ ManageUserDetailView.as_view(), name="manage_user_detail" ), + url( + r'^feature_based_enrollments/?$', + FeatureBasedEnrollmentsSupportView.as_view(), + name="feature_based_enrollments" + ), ] diff --git a/lms/djangoapps/support/views/feature_based_enrollments.py b/lms/djangoapps/support/views/feature_based_enrollments.py new file mode 100644 index 0000000000..79fbe9cc36 --- /dev/null +++ b/lms/djangoapps/support/views/feature_based_enrollments.py @@ -0,0 +1,72 @@ +""" +Support tool for viewing course duration information +""" +from django.core.exceptions import ObjectDoesNotExist +from django.utils.decorators import method_decorator +from django.views.generic import View +from edxmako.shortcuts import render_to_response +from lms.djangoapps.support.decorators import require_support_permission +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from openedx.features.course_duration_limits.models import CourseDurationLimitConfig +from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class FeatureBasedEnrollmentsSupportView(View): + """ + View for listing course duration settings for + support team. + """ + @method_decorator(require_support_permission) + def get(self, request): + """ + Render the course duration tool view. + """ + course_key = request.GET.get('course_key', '') + + if course_key: + results = self._get_course_duration_info(course_key) + else: + results = [] + + return render_to_response('support/feature_based_enrollments.html', { + 'course_key': course_key, + 'results': results, + }) + + def _get_course_duration_info(self, course_key): + """ + Fetch course duration information from database + """ + results = [] + + try: + key = CourseKey.from_string(course_key) + course = CourseOverview.objects.values('display_name').get(id=key) + duration_config = CourseDurationLimitConfig.current(course_key=key) + gating_config = ContentTypeGatingConfig.current(course_key=key) + misconfigured = duration_config.enabled != gating_config.enabled + + if misconfigured: + enabled = 'Partial' + enabled_as_of = 'Misconfigured' + reason = 'Misconfiguration' + else: + enabled = duration_config.enabled or False + enabled_as_of = str(duration_config.enabled_as_of) if duration_config.enabled_as_of else 'N/A' + reason = duration_config.provenances['enabled'] + + data = { + 'course_id': course_key, + 'course_name': course.get('display_name'), + 'enabled': enabled, + 'enabled_as_of': enabled_as_of, + 'reason': reason, + } + results.append(data) + + except (ObjectDoesNotExist, InvalidKeyError): + pass + + return results diff --git a/lms/djangoapps/support/views/index.py b/lms/djangoapps/support/views/index.py index 8b6630fe5e..5bd0ea07a9 100644 --- a/lms/djangoapps/support/views/index.py +++ b/lms/djangoapps/support/views/index.py @@ -36,6 +36,11 @@ SUPPORT_INDEX_URLS = [ "name": _("Entitlements"), "description": _("View, create, and reissue learner entitlements"), }, + { + "url": reverse_lazy("support:feature_based_enrollments"), + "name": _("Feature Based Enrollments"), + "description": _("View feature based enrollment settings"), + }, ] diff --git a/lms/static/sass/views/_support.scss b/lms/static/sass/views/_support.scss index 10a15744ea..0c8391d4e5 100644 --- a/lms/static/sass/views/_support.scss +++ b/lms/static/sass/views/_support.scss @@ -185,6 +185,34 @@ text-align: center; } +.fb-enrollments-results { + .fb-enrollments-table { + display: inline-block; + } + + th { + @extend %t-title7; + + text-align: center; + } + + td{ + padding: 0 23px; + } +} + +.fb-enrollments-content{ + text-align: center; +} + +.fb-enrollments-search{ + margin: 40px 0; + + input[name="course_key"] { + width: 350px; + } +} + .contact-us-wrapper { min-width: auto; diff --git a/lms/templates/support/feature_based_enrollments.html b/lms/templates/support/feature_based_enrollments.html new file mode 100644 index 0000000000..38d312598b --- /dev/null +++ b/lms/templates/support/feature_based_enrollments.html @@ -0,0 +1,56 @@ +<%page expression_filter="h"/> + +<%! +from django.utils.translation import ugettext as _ +%> + +<%namespace name='static' file='../static_content.html'/> +<%inherit file="../main.html" /> + +<%block name="pagetitle"> +${_("Feature Based Enrollments")} + + +<%block name="content"> +
+

${_("Student Support: Feature Based Enrollments")}

+
+ + +
+ % if len(results) > 0: + + + + + + + + + + + + % for data in results: + + + + + + + + % endfor + +
${_("Course ID")}${_("Course Name")}${_("Is Enabled")}${_("Enabled As Of")}${_("Reason")}
${data.get('course_id')}${data.get('course_name')}${data.get('enabled')}${data.get('enabled_as_of')}${data.get('reason')}
+ % elif course_key: +
${_("No results found")}
+ % endif +
+
+
+ diff --git a/openedx/core/djangoapps/config_model_utils/models.py b/openedx/core/djangoapps/config_model_utils/models.py index 74529f41b3..737a6f2f0e 100644 --- a/openedx/core/djangoapps/config_model_utils/models.py +++ b/openedx/core/djangoapps/config_model_utils/models.py @@ -7,6 +7,9 @@ StackedConfigurationModel: A ConfigurationModel that can be overridden at site, # -*- coding: utf-8 -*- from __future__ import unicode_literals +from collections import defaultdict +from enum import Enum + from django.conf import settings from django.db import models from django.db.models import Q, F @@ -21,6 +24,17 @@ from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +class Provenance(Enum): + """ + Provenance enum + """ + course = _('Course') + org = _('Org') + site = _('Site') + global_ = _('Global') + default = _('Default') + + class StackedConfigurationModel(ConfigurationModel): """ A ConfigurationModel that stacks Global, Site, Org, and Course level @@ -134,16 +148,91 @@ class StackedConfigurationModel(ConfigurationModel): F('site').desc(nulls_first=True), ) + provenances = defaultdict(lambda: Provenance.default) for override in overrides: for field in stackable_fields: value = field.value_from_object(override) if value != field_defaults[field.name]: values[field.name] = value + if override.course_id is not None: + provenances[field.name] = Provenance.course + elif override.org is not None: + provenances[field.name] = Provenance.org + elif override.site_id is not None: + provenances[field.name] = Provenance.site + else: + provenances[field.name] = Provenance.global_ current = cls(**values) + current.provenances = {field.name: provenances[field.name] for field in stackable_fields} # pylint: disable=attribute-defined-outside-init cache.set(cache_key_name, current, cls.cache_timeout) return current + @classmethod + def all_current_course_configs(cls): + """ + Return configuration for all courses + """ + all_courses = CourseOverview.objects.all() + all_site_configs = SiteConfiguration.objects.filter( + values__contains='course_org_filter', enabled=True + ).select_related('site') + + try: + default_site = Site.objects.get(id=settings.SITE_ID) + except Site.DoesNotExist: + default_site = RequestSite(crum.get_current_request()) + + sites_by_org = defaultdict(lambda: default_site) + site_cfg_org_filters = ( + (site_cfg.site, site_cfg.values['course_org_filter']) + for site_cfg in all_site_configs + ) + sites_by_org.update({ + org: site + for (site, orgs) in site_cfg_org_filters + for org in (orgs if isinstance(orgs, list) else [orgs]) + }) + + all_overrides = cls.objects.current_set() + overrides = { + (override.site_id, override.org, override.course_id): override + for override in all_overrides + } + + stackable_fields = [cls._meta.get_field(field_name) for field_name in cls.STACKABLE_FIELDS] + field_defaults = { + field.name: field.get_default() + for field in stackable_fields + } + + def provenance(course, field): + """ + Return provenance for given field + """ + for (config_key, provenance) in [ + ((None, None, course.id), Provenance.course), + ((None, course.id.org, None), Provenance.org), + ((sites_by_org[course.id.org].id, None, None), Provenance.site), + ((None, None, None), Provenance.global_), + ]: + config = overrides.get(config_key) + if config is None: + continue + value = field.value_from_object(config) + if value != field_defaults[field.name]: + return (value, provenance) + + return (field_defaults[field.name], Provenance.default) + + return { + course.id: { + field.name: provenance(course, field) + for field in stackable_fields + } + for course in all_courses + } + @classmethod def cache_key_name(cls, site, org, course=None, course_key=None): # pylint: disable=arguments-differ if course is not None and course_key is not None: diff --git a/openedx/features/content_type_gating/tests/test_models.py b/openedx/features/content_type_gating/tests/test_models.py index 715eccd735..ec8f202f4e 100644 --- a/openedx/features/content_type_gating/tests/test_models.py +++ b/openedx/features/content_type_gating/tests/test_models.py @@ -4,9 +4,12 @@ import itertools import ddt from django.utils import timezone from mock import Mock +import pytz -from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory +from opaque_keys.edx.locator import CourseLocator +from openedx.core.djangoapps.config_model_utils.models import Provenance from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.features.content_type_gating.models import ContentTypeGatingConfig @@ -173,6 +176,62 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): self.assertEqual(expected_org_setting, ContentTypeGatingConfig.current(org=test_course.org).enabled) self.assertEqual(expected_course_setting, ContentTypeGatingConfig.current(course_key=test_course.id).enabled) + def test_all_current_course_configs(self): + # Set up test objects + for global_setting in (True, False, None): + ContentTypeGatingConfig.objects.create(enabled=global_setting, enabled_as_of=datetime(2018, 1, 1)) + for site_setting in (True, False, None): + test_site_cfg = SiteConfigurationFactory.create(values={'course_org_filter': []}) + ContentTypeGatingConfig.objects.create(site=test_site_cfg.site, enabled=site_setting, enabled_as_of=datetime(2018, 1, 1)) + + for org_setting in (True, False, None): + test_org = "{}-{}".format(test_site_cfg.id, org_setting) + test_site_cfg.values['course_org_filter'].append(test_org) + test_site_cfg.save() + + ContentTypeGatingConfig.objects.create(org=test_org, enabled=org_setting, enabled_as_of=datetime(2018, 1, 1)) + + for course_setting in (True, False, None): + test_course = CourseOverviewFactory.create( + org=test_org, + id=CourseLocator(test_org, 'test_course', 'run-{}'.format(course_setting)) + ) + ContentTypeGatingConfig.objects.create(course=test_course, enabled=course_setting, enabled_as_of=datetime(2018, 1, 1)) + + with self.assertNumQueries(4): + all_configs = ContentTypeGatingConfig.all_current_course_configs() + + # Deliberatly using the last all_configs that was checked after the 3rd pass through the global_settings loop + # We should be creating 3^4 courses (3 global values * 3 site values * 3 org values * 3 course values) + # Plus 1 for the edX/toy/2012_Fall course + self.assertEqual(len(all_configs), 3**4 + 1) + + # Point-test some of the final configurations + self.assertEqual( + all_configs[CourseLocator('7-True', 'test_course', 'run-None')], + { + 'enabled': (True, Provenance.org), + 'enabled_as_of': (datetime(2018, 1, 1, 5, tzinfo=pytz.UTC), Provenance.course), + 'studio_override_enabled': (None, Provenance.default), + } + ) + self.assertEqual( + all_configs[CourseLocator('7-True', 'test_course', 'run-False')], + { + 'enabled': (False, Provenance.course), + 'enabled_as_of': (datetime(2018, 1, 1, 5, tzinfo=pytz.UTC), Provenance.course), + 'studio_override_enabled': (None, Provenance.default), + } + ) + self.assertEqual( + all_configs[CourseLocator('7-None', 'test_course', 'run-None')], + { + 'enabled': (True, Provenance.site), + 'enabled_as_of': (datetime(2018, 1, 1, 5, tzinfo=pytz.UTC), Provenance.course), + 'studio_override_enabled': (None, Provenance.default), + } + ) + def test_caching_global(self): global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() diff --git a/openedx/features/course_duration_limits/tests/test_models.py b/openedx/features/course_duration_limits/tests/test_models.py index a0468b27ff..0bc4595482 100644 --- a/openedx/features/course_duration_limits/tests/test_models.py +++ b/openedx/features/course_duration_limits/tests/test_models.py @@ -8,6 +8,10 @@ import itertools import ddt from django.utils import timezone from mock import Mock +import pytz + +from opaque_keys.edx.locator import CourseLocator +from openedx.core.djangoapps.config_model_utils.models import Provenance from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag @@ -199,6 +203,65 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): self.assertEqual(expected_org_setting, CourseDurationLimitConfig.current(org=test_course.org).enabled) self.assertEqual(expected_course_setting, CourseDurationLimitConfig.current(course_key=test_course.id).enabled) + def test_all_current_course_configs(self): + # Set up test objects + for global_setting in (True, False, None): + CourseDurationLimitConfig.objects.create(enabled=global_setting, enabled_as_of=datetime(2018, 1, 1)) + for site_setting in (True, False, None): + test_site_cfg = SiteConfigurationFactory.create(values={'course_org_filter': []}) + CourseDurationLimitConfig.objects.create( + site=test_site_cfg.site, enabled=site_setting, enabled_as_of=datetime(2018, 1, 1) + ) + + for org_setting in (True, False, None): + test_org = "{}-{}".format(test_site_cfg.id, org_setting) + test_site_cfg.values['course_org_filter'].append(test_org) + test_site_cfg.save() + + CourseDurationLimitConfig.objects.create( + org=test_org, enabled=org_setting, enabled_as_of=datetime(2018, 1, 1) + ) + + for course_setting in (True, False, None): + test_course = CourseOverviewFactory.create( + org=test_org, + id=CourseLocator(test_org, 'test_course', 'run-{}'.format(course_setting)) + ) + CourseDurationLimitConfig.objects.create( + course=test_course, enabled=course_setting, enabled_as_of=datetime(2018, 1, 1) + ) + + with self.assertNumQueries(4): + all_configs = CourseDurationLimitConfig.all_current_course_configs() + + # Deliberatly using the last all_configs that was checked after the 3rd pass through the global_settings loop + # We should be creating 3^4 courses (3 global values * 3 site values * 3 org values * 3 course values) + # Plus 1 for the edX/toy/2012_Fall course + self.assertEqual(len(all_configs), 3**4 + 1) + + # Point-test some of the final configurations + self.assertEqual( + all_configs[CourseLocator('7-True', 'test_course', 'run-None')], + { + 'enabled': (True, Provenance.org), + 'enabled_as_of': (datetime(2018, 1, 1, 5, tzinfo=pytz.UTC), Provenance.course), + } + ) + self.assertEqual( + all_configs[CourseLocator('7-True', 'test_course', 'run-False')], + { + 'enabled': (False, Provenance.course), + 'enabled_as_of': (datetime(2018, 1, 1, 5, tzinfo=pytz.UTC), Provenance.course), + } + ) + self.assertEqual( + all_configs[CourseLocator('7-None', 'test_course', 'run-None')], + { + 'enabled': (True, Provenance.site), + 'enabled_as_of': (datetime(2018, 1, 1, 5, tzinfo=pytz.UTC), Provenance.course), + } + ) + def test_caching_global(self): global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save()