Add feature-based enrollment search to support form
This commit is contained in:
@@ -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"
|
||||
),
|
||||
]
|
||||
|
||||
72
lms/djangoapps/support/views/feature_based_enrollments.py
Normal file
72
lms/djangoapps/support/views/feature_based_enrollments.py
Normal file
@@ -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
|
||||
@@ -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"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
56
lms/templates/support/feature_based_enrollments.html
Normal file
56
lms/templates/support/feature_based_enrollments.html
Normal file
@@ -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>
|
||||
|
||||
<%block name="content">
|
||||
<section class="container outside-app">
|
||||
<h1>${_("Student Support: Feature Based Enrollments")}</h1>
|
||||
<div class="fb-enrollments-content">
|
||||
<div class="fb-enrollments-search">
|
||||
<form class="fb-enrollments-form">
|
||||
<label class="sr" for="course-query-input">Search</label>
|
||||
<input id="course-query-input" type="text" name="course_key" value="${course_key}" placeholder="Course Id">
|
||||
<input type="submit" value="Search" class="btn-disable-on-submit">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="fb-enrollments-results">
|
||||
% if len(results) > 0:
|
||||
<table id="fb-enrollments-table" class="fb-enrollments-table display compact nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${_("Course ID")}</th>
|
||||
<th>${_("Course Name")}</th>
|
||||
<th>${_("Is Enabled")}</th>
|
||||
<th>${_("Enabled As Of")}</th>
|
||||
<th>${_("Reason")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
% for data in results:
|
||||
<tr>
|
||||
<td>${data.get('course_id')}</td>
|
||||
<td>${data.get('course_name')}</td>
|
||||
<td>${data.get('enabled')}</td>
|
||||
<td>${data.get('enabled_as_of')}</td>
|
||||
<td>${data.get('reason')}</td>
|
||||
</tr>
|
||||
% endfor
|
||||
</tbody>
|
||||
</table>
|
||||
% elif course_key:
|
||||
<div>${_("No results found")}</div>
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</%block>
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user