Commit eaab2cf4 by Robert Raposa

Add course overrides of waffle flags.

parent 867fac31
......@@ -1010,6 +1010,9 @@ INSTALLED_APPS = (
# Customized celery tasks, including persisting failed tasks so they can
# be retried
'celery_utils',
# Waffle related utilities
'openedx.core.djangoapps.waffle_utils',
)
......
......@@ -2267,7 +2267,7 @@ def auto_auth(request):
elif course_id:
# Redirect to the course homepage (in LMS) or outline page (in Studio)
try:
redirect_url = reverse(course_home_url_name(request), kwargs={'course_id': course_id})
redirect_url = reverse(course_home_url_name(course_key), kwargs={'course_id': course_id})
except NoReverseMatch:
redirect_url = reverse('course_handler', kwargs={'course_key_string': course_id})
else:
......
......@@ -231,18 +231,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default,
# # of mongo queries,
# )
('no_overrides', 1, True, False): (24, 1),
('no_overrides', 2, True, False): (24, 1),
('no_overrides', 3, True, False): (24, 1),
('ccx', 1, True, False): (24, 1),
('ccx', 2, True, False): (24, 1),
('ccx', 3, True, False): (24, 1),
('no_overrides', 1, False, False): (24, 1),
('no_overrides', 2, False, False): (24, 1),
('no_overrides', 3, False, False): (24, 1),
('ccx', 1, False, False): (24, 1),
('ccx', 2, False, False): (24, 1),
('ccx', 3, False, False): (24, 1),
('no_overrides', 1, True, False): (25, 1),
('no_overrides', 2, True, False): (25, 1),
('no_overrides', 3, True, False): (25, 1),
('ccx', 1, True, False): (25, 1),
('ccx', 2, True, False): (25, 1),
('ccx', 3, True, False): (25, 1),
('no_overrides', 1, False, False): (25, 1),
('no_overrides', 2, False, False): (25, 1),
('no_overrides', 3, False, False): (25, 1),
('ccx', 1, False, False): (25, 1),
('ccx', 2, False, False): (25, 1),
('ccx', 3, False, False): (25, 1),
}
......@@ -254,19 +254,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True
TEST_DATA = {
('no_overrides', 1, True, False): (24, 3),
('no_overrides', 2, True, False): (24, 3),
('no_overrides', 3, True, False): (24, 3),
('ccx', 1, True, False): (24, 3),
('ccx', 2, True, False): (24, 3),
('ccx', 3, True, False): (24, 3),
('ccx', 1, True, True): (25, 3),
('ccx', 2, True, True): (25, 3),
('ccx', 3, True, True): (25, 3),
('no_overrides', 1, False, False): (24, 3),
('no_overrides', 2, False, False): (24, 3),
('no_overrides', 3, False, False): (24, 3),
('ccx', 1, False, False): (24, 3),
('ccx', 2, False, False): (24, 3),
('ccx', 3, False, False): (24, 3),
('no_overrides', 1, True, False): (25, 3),
('no_overrides', 2, True, False): (25, 3),
('no_overrides', 3, True, False): (25, 3),
('ccx', 1, True, False): (25, 3),
('ccx', 2, True, False): (25, 3),
('ccx', 3, True, False): (25, 3),
('ccx', 1, True, True): (26, 3),
('ccx', 2, True, True): (26, 3),
('ccx', 3, True, True): (26, 3),
('no_overrides', 1, False, False): (25, 3),
('no_overrides', 2, False, False): (25, 3),
('no_overrides', 3, False, False): (25, 3),
('ccx', 1, False, False): (25, 3),
('ccx', 2, False, False): (25, 3),
('ccx', 3, False, False): (25, 3),
}
......@@ -2,15 +2,14 @@
This module is essentially a broker to xmodule/tabs.py -- it was originally introduced to
perform some LMS-specific tab display gymnastics for the Entrance Exams feature
"""
import waffle
from django.conf import settings
from django.utils.translation import ugettext as _, ugettext_noop
from courseware.access import has_access
from courseware.entrance_exams import user_can_skip_entrance_exam
from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.features.course_experience import default_course_url_name, UNIFIED_COURSE_EXPERIENCE_FLAG
from openedx.features.course_experience import default_course_url_name
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from request_cache.middleware import RequestCache
from student.models import CourseEnrollment
from xmodule.tabs import CourseTab, CourseTabList, key_checker, link_reverse_func
......@@ -66,8 +65,7 @@ class CourseInfoTab(CourseTab):
"""
The "Home" tab is not shown for the new unified course experience.
"""
request = RequestCache.get_current_request()
return not waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG)
return not UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id)
class SyllabusTab(EnrolledTab):
......
......@@ -7,8 +7,6 @@ from django.core.urlresolvers import reverse
from freezegun import freeze_time
from nose.plugins.attrib import attr
from pytz import utc
from waffle.testutils import override_flag
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
from commerce.models import CommerceConfiguration
from course_modes.tests.factories import CourseModeFactory
......@@ -24,6 +22,8 @@ from courseware.date_summary import (
)
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.verify_student.models import VerificationDeadline
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
......@@ -194,7 +194,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info',
'openedx.course_experience.course_home',
)
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_todays_date_no_timezone(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
......@@ -218,7 +218,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info',
'openedx.course_experience.course_home',
)
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_todays_date_timezone(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
......@@ -249,7 +249,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info',
'openedx.course_experience.course_home',
)
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_start_date_render(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
......@@ -267,7 +267,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
'info',
'openedx.course_experience.course_home',
)
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_start_date_render_time_zone(self, url_name):
with freeze_time('2015-01-02'):
self.setup_course_and_user()
......
......@@ -2,8 +2,6 @@
Test cases for tabs.
"""
from waffle.testutils import override_flag
from django.core.urlresolvers import reverse
from django.http import Http404
from mock import MagicMock, Mock, patch
......@@ -18,7 +16,8 @@ from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.factories import InstructorFactory, StaffFactory
from courseware.views.views import get_static_tab_fragment, StaticCourseTabView
from openedx.core.djangolib.testing.utils import get_mock_request
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from util.milestones_helpers import (
......@@ -776,12 +775,13 @@ class CourseInfoTabTestCase(TabTestCase):
self.user = self.create_mock_user()
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)
self.assertEqual(tabs[0].type, 'course_info')
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
@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)
......
......@@ -210,8 +210,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20
@ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 142),
(ModuleStoreEnum.Type.split, 4, 142),
(ModuleStoreEnum.Type.mongo, 10, 143),
(ModuleStoreEnum.Type.split, 4, 143),
)
@ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
......@@ -1420,12 +1420,12 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced)
with self.assertNumQueries(40), check_mongo_calls(1):
with self.assertNumQueries(41), check_mongo_calls(1):
self._get_progress_page()
@ddt.data(
(False, 40, 26),
(True, 33, 22)
(False, 41, 27),
(True, 34, 23)
)
@ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent):
......
......@@ -86,9 +86,9 @@ from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.features.course_experience import (
UNIFIED_COURSE_EXPERIENCE_FLAG,
UNIFIED_COURSE_TAB_FLAG,
UNIFIED_COURSE_VIEW_FLAG,
course_home_url_name
course_home_url_name,
)
from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView
from openedx.features.enterprise_support.api import data_sharing_consent_required
......@@ -263,11 +263,12 @@ def course_info(request, course_id):
return url
return None
course_key = CourseKey.from_string(course_id)
# If the unified course experience is enabled, redirect to the "Course" tab
if waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG):
return redirect(reverse(course_home_url_name(request), args=[course_id]))
if UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key):
return redirect(reverse(course_home_url_name(course_key), args=[course_id]))
course_key = CourseKey.from_string(course_id)
with modulestore().bulk_operations(course_key):
course = get_course_by_id(course_key, depth=2)
access_response = has_access(request.user, 'load', course, course_key)
......@@ -669,7 +670,7 @@ def course_about(request, course_id):
modes = CourseMode.modes_for_course_dict(course_key)
if configuration_helpers.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)):
return redirect(reverse(course_home_url_name(request), args=[unicode(course.id)]))
return redirect(reverse(course_home_url_name(course.id), args=[unicode(course.id)]))
registered = registered_for_course(course, request.user)
......@@ -677,7 +678,7 @@ def course_about(request, course_id):
studio_url = get_studio_url(course, 'settings/details')
if has_access(request.user, 'load', course):
course_target = reverse(course_home_url_name(request), args=[course.id.to_deprecated_string()])
course_target = reverse(course_home_url_name(course.id), args=[course.id.to_deprecated_string()])
else:
course_target = reverse('about_course', args=[course.id.to_deprecated_string()])
......@@ -1241,7 +1242,7 @@ def course_survey(request, course_id):
course_key = CourseKey.from_string(course_id)
course = get_course_with_access(request.user, 'load', course_key)
redirect_url = reverse(course_home_url_name(request), args=[course_id])
redirect_url = reverse(course_home_url_name(course.id), args=[course_id])
# if there is no Survey associated with this course,
# then redirect to the course instead
......
......@@ -2,7 +2,7 @@
This module contains various configuration settings via
waffle switches for the Grades app.
"""
from openedx.core.djangolib.waffle_utils import WaffleSwitchPlus
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
# Namespace
......@@ -18,4 +18,4 @@ def waffle():
"""
Returns the namespaced, cached, audited Waffle class for Grades.
"""
return WaffleSwitchPlus(namespace=WAFFLE_NAMESPACE, log_prefix=u'Grades: ')
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Grades: ')
......@@ -2211,6 +2211,9 @@ INSTALLED_APPS = (
# Unusual migrations
'database_fixups',
# Waffle related utilities
'openedx.core.djangoapps.waffle_utils',
# Features
'openedx.features.course_bookmarks',
'openedx.features.course_experience',
......
......@@ -56,7 +56,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_
% endif
<div class="course-container">
<article class="course${mode_class}">
<% course_target = reverse(course_home_url_name(), args=[unicode(course_overview.id)]) %>
<% course_target = reverse(course_home_url_name(course_overview.id), args=[unicode(course_overview.id)]) %>
<section class="details" aria-labelledby="details-heading-${course_overview.number}">
<h2 class="hd hd-2 sr" id="details-heading-${course_overview.number}">${_('Course details')}</h2>
<div class="wrapper-course-image" aria-hidden="true">
......
......@@ -75,7 +75,7 @@ from openedx.features.course_experience import course_home_url_name
</div>
% if not reg_code_already_redeemed:
%if redemption_success:
<% course_url = reverse(course_home_url_name(), args=[course.id.to_deprecated_string()]) %>
<% course_url = reverse(course_home_url_name(course.id), args=[course.id.to_deprecated_string()]) %>
<a href="${course_url}" class="link-button course-link-bg-color">${_("View Course")} <span class="icon fa fa-caret-right" aria-hidden="true"></span></a>
%elif not registered_for_course:
<form method="post">
......
......@@ -55,6 +55,6 @@ class HelpModalTests(ModuleStoreTestCase):
Simple test to make sure that you don't get a 500 error when the modal
is enabled.
"""
url = reverse(course_home_url_name(), args=[self.course.id.to_deprecated_string()])
url = reverse(course_home_url_name(self.course.id), args=[self.course.id.to_deprecated_string()])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
......@@ -2,7 +2,7 @@
This module contains various configuration settings via
waffle switches for the Block Structure framework.
"""
from openedx.core.djangolib.waffle_utils import WaffleSwitchPlus
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from request_cache.middleware import request_cached
from .models import BlockStructureConfiguration
......@@ -22,7 +22,7 @@ def waffle():
"""
Returns the namespaced and cached Waffle class for BlockStructures.
"""
return WaffleSwitchPlus(namespace=WAFFLE_NAMESPACE, log_prefix=u'BlockStructure: ')
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'BlockStructure: ')
@request_cached
......
......@@ -19,7 +19,7 @@ except ImportError:
import psutil
import request_cache
from openedx.core.djangolib.waffle_utils import WaffleSwitchPlus
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
REQUEST_CACHE_KEY = 'monitoring_custom_metrics'
......@@ -163,4 +163,4 @@ class MonitoringMemoryMiddleware(object):
"""
Returns whether this middleware is enabled.
"""
return WaffleSwitchPlus(namespace=WAFFLE_NAMESPACE).is_enabled(u'enable_memory_middleware')
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE).is_enabled(u'enable_memory_middleware')
"""
Django admin page for waffle utils models
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from .forms import WaffleFlagCourseOverrideAdminForm
from .models import WaffleFlagCourseOverrideModel
class WaffleFlagCourseOverrideAdmin(KeyedConfigurationModelAdmin):
"""
Admin for course override of waffle flags.
Includes search by course_id and waffle_flag.
"""
form = WaffleFlagCourseOverrideAdminForm
search_fields = ['waffle_flag', 'course_id']
fieldsets = (
(None, {
'fields': ('waffle_flag', 'course_id', 'override_choice', 'enabled'),
'description': 'Enter a valid course id and an existing waffle flag. The waffle flag name is not validated.'
}),
)
admin.site.register(WaffleFlagCourseOverrideModel, WaffleFlagCourseOverrideAdmin)
"""
Defines a form for providing validation of subsection grade templates.
"""
from django import forms
from openedx.core.lib.courses import clean_course_id
from .models import WaffleFlagCourseOverrideModel
class WaffleFlagCourseOverrideAdminForm(forms.ModelForm):
"""
Input form for course override of waffle flags, allowing us to verify data.
"""
class Meta(object):
model = WaffleFlagCourseOverrideModel
fields = '__all__'
def clean_course_id(self):
"""
Validate the course id
"""
return clean_course_id(self)
def clean_waffle_flag(self):
"""
Validate the waffle flag is an existing flag.
"""
cleaned_flag = self.cleaned_data['waffle_flag']
if not cleaned_flag:
msg = u'Waffle flag must be supplied.'
raise forms.ValidationError(msg)
return cleaned_flag.strip()
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import openedx.core.djangoapps.xmodule_django.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='WaffleFlagCourseOverrideModel',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('waffle_flag', models.CharField(max_length=255, db_index=True)),
('course_id', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)),
('override_choice', models.CharField(default=b'on', max_length=3, choices=[(b'on', 'Force On'), (b'off', 'Force Off')])),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'verbose_name': 'Waffle flag course override',
'verbose_name_plural': 'Waffle flag course overrides',
},
),
]
"""
Models for configuring waffle utils.
"""
from django.db.models import CharField
from django.utils.translation import ugettext_lazy as _
from model_utils import Choices
from config_models.models import ConfigurationModel
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from request_cache.middleware import request_cached
class WaffleFlagCourseOverrideModel(ConfigurationModel):
"""
Used to force a waffle flag on or off for a course.
"""
OVERRIDE_CHOICES = Choices(('on', _('Force On')), ('off', _('Force Off')))
ALL_CHOICES = OVERRIDE_CHOICES + Choices('unset')
KEY_FIELDS = ('waffle_flag', 'course_id')
# The course that these features are attached to.
waffle_flag = CharField(max_length=255, db_index=True)
course_id = CourseKeyField(max_length=255, db_index=True)
override_choice = CharField(choices=OVERRIDE_CHOICES, default=OVERRIDE_CHOICES.on, max_length=3)
@classmethod
@request_cached
def override_value(cls, waffle_flag, course_id):
"""
Returns whether the waffle flag was overridden (on or off) for the
course, or is unset.
Arguments:
waffle_flag (String): The name of the flag.
course_id (CourseKey): The course id for which the flag may have
been overridden.
If the current config is not set or disabled for this waffle flag and
course id, returns ALL_CHOICES.unset.
Otherwise, returns ALL_CHOICES.on or ALL_CHOICES.off as configured for
the override_choice.
"""
if not course_id or not waffle_flag:
return cls.ALL_CHOICES.unset
effective = cls.objects.filter(waffle_flag=waffle_flag, course_id=course_id).order_by('-change_date').first()
if effective and effective.enabled:
return effective.override_choice
return cls.ALL_CHOICES.unset
class Meta(object):
app_label = "waffle_utils"
verbose_name = 'Waffle flag course override'
verbose_name_plural = 'Waffle flag course overrides'
def __unicode__(self):
enabled_label = "Enabled" if self.enabled else "Not Enabled"
# pylint: disable=no-member
return u"Course '{}': Persistent Grades {}".format(self.course_id.to_deprecated_string(), enabled_label)
"""
Tests for waffle utils features.
"""
import ddt
from django.test import TestCase
from mock import patch
from opaque_keys.edx.keys import CourseKey
from waffle.testutils import override_flag
from request_cache.middleware import RequestCache
from .. import CourseWaffleFlag, WaffleFlagNamespace
from ..models import WaffleFlagCourseOverrideModel
@ddt.ddt
class TestCourseWaffleFlag(TestCase):
"""
Tests the CourseWaffleFlag.
"""
NAMESPACE_NAME = "test_namespace"
FLAG_NAME = "test_flag"
NAMESPACED_FLAG_NAME = NAMESPACE_NAME + "." + FLAG_NAME
TEST_COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course")
TEST_NAMESPACE = WaffleFlagNamespace(NAMESPACE_NAME)
TEST_COURSE_FLAG = CourseWaffleFlag(TEST_NAMESPACE, FLAG_NAME)
@ddt.data(
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.on, 'waffle_enabled': False, 'result': True},
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.off, 'waffle_enabled': True, 'result': False},
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.unset, 'waffle_enabled': True, 'result': True},
{'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.unset, 'waffle_enabled': False, 'result': False},
)
def test_course_waffle_flag(self, data):
"""
Tests various combinations of a flag being set in waffle and overridden
for a course.
"""
RequestCache.clear_request_cache()
with patch.object(WaffleFlagCourseOverrideModel, 'override_value', return_value=data['course_override']):
with override_flag(self.NAMESPACED_FLAG_NAME, active=data['waffle_enabled']):
# check twice to test that the result is properly cached
self.assertEqual(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY), data['result'])
self.assertEqual(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY), data['result'])
# result is cached, so override check should happen once
WaffleFlagCourseOverrideModel.override_value.assert_called_once_with(
self.NAMESPACED_FLAG_NAME,
self.TEST_COURSE_KEY
)
"""
Tests for waffle utils models.
"""
from ddt import data, ddt, unpack
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from request_cache.middleware import RequestCache
from ..models import WaffleFlagCourseOverrideModel
@ddt
class WaffleFlagCourseOverrideTests(TestCase):
"""
Tests for the waffle flag course override model.
"""
WAFFLE_TEST_NAME = "waffle_test_course_override"
TEST_COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course")
OVERRIDE_CHOICES = WaffleFlagCourseOverrideModel.ALL_CHOICES
# Data format: ( is_enabled, override_choice, expected_result )
@data((True, OVERRIDE_CHOICES.on, OVERRIDE_CHOICES.on),
(True, OVERRIDE_CHOICES.off, OVERRIDE_CHOICES.off),
(False, OVERRIDE_CHOICES.on, OVERRIDE_CHOICES.unset))
@unpack
def test_setting_override(self, is_enabled, override_choice, expected_result):
RequestCache.clear_request_cache()
self.set_waffle_course_override(override_choice, is_enabled)
override_value = WaffleFlagCourseOverrideModel.override_value(
self.WAFFLE_TEST_NAME, self.TEST_COURSE_KEY
)
self.assertEqual(override_value, expected_result)
def test_setting_override_multiple_times(self):
RequestCache.clear_request_cache()
self.set_waffle_course_override(self.OVERRIDE_CHOICES.on)
self.set_waffle_course_override(self.OVERRIDE_CHOICES.off)
override_value = WaffleFlagCourseOverrideModel.override_value(
self.WAFFLE_TEST_NAME, self.TEST_COURSE_KEY
)
self.assertEqual(override_value, self.OVERRIDE_CHOICES.off)
def set_waffle_course_override(self, override_choice, is_enabled=True):
WaffleFlagCourseOverrideModel.objects.create(
waffle_flag=self.WAFFLE_TEST_NAME,
override_choice=override_choice,
enabled=is_enabled,
course_id=self.TEST_COURSE_KEY
)
"""
Tests for waffle utils test utilities.
"""
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from request_cache.middleware import RequestCache
from .. import CourseWaffleFlag, WaffleFlagNamespace
from ..testutils import override_waffle_flag
class OverrideWaffleFlagTests(TestCase):
"""
Tests for the override_waffle_flag decorator.
"""
NAMESPACE_NAME = "test_namespace"
FLAG_NAME = "test_flag"
NAMESPACED_FLAG_NAME = NAMESPACE_NAME + "." + FLAG_NAME
TEST_COURSE_KEY = CourseKey.from_string("edX/DemoX/Demo_Course")
TEST_NAMESPACE = WaffleFlagNamespace(NAMESPACE_NAME)
TEST_COURSE_FLAG = CourseWaffleFlag(TEST_NAMESPACE, FLAG_NAME)
def setUp(self):
super(OverrideWaffleFlagTests, self).setUp()
RequestCache.clear_request_cache()
@override_waffle_flag(TEST_COURSE_FLAG, True)
def check_is_enabled_with_decorator(self):
# test flag while overridden with decorator
self.assertTrue(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY))
def test_override_waffle_flag_pre_cached(self):
# checks and caches the is_enabled value
self.assertFalse(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY))
flag_cache = self.TEST_COURSE_FLAG.waffle_namespace._cached_flags
self.assertIn(self.NAMESPACED_FLAG_NAME, flag_cache)
# test flag while overridden with decorator
self.check_is_enabled_with_decorator()
# test cached flag is restored
self.assertIn(self.NAMESPACED_FLAG_NAME, flag_cache)
self.assertEquals(self.TEST_COURSE_FLAG.is_enabled(self.TEST_COURSE_KEY), False)
def test_override_waffle_flag_not_pre_cached(self):
# check that the flag is not yet cached
flag_cache = self.TEST_COURSE_FLAG.waffle_namespace._cached_flags
self.assertNotIn(self.NAMESPACED_FLAG_NAME, flag_cache)
# test flag while overridden with decorator
self.check_is_enabled_with_decorator()
# test cache is removed when no longer using decorator/context manager
self.assertNotIn(self.NAMESPACED_FLAG_NAME, flag_cache)
"""
Test utilities for waffle utilities.
"""
from functools import wraps
from waffle.testutils import override_flag
def override_waffle_flag(flag, active):
"""
To be used as a decorator for a test function to override a namespaced
waffle flag.
flag (WaffleFlag): The namespaced cached waffle flag.
active (Boolean): The value to which the flag will be set.
Example usage:
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
"""
def real_decorator(function):
"""
Actual decorator function.
"""
@wraps(function)
def wrapper(*args, **kwargs):
"""
Provides the actual override functionality of the decorator.
Saves the previous cached value of the flag and restores it (if it
was set), after overriding it.
"""
waffle_namespace = flag.waffle_namespace
namespaced_flag_name = waffle_namespace._namespaced_name(flag.flag_name)
# save previous value and whether it existed in the cache
cached_value_existed = namespaced_flag_name in waffle_namespace._cached_flags
if cached_value_existed:
previous_value = waffle_namespace._cached_flags[namespaced_flag_name]
# set new value
waffle_namespace._cached_flags[namespaced_flag_name] = active
with override_flag(namespaced_flag_name, active):
# call wrapped function
function(*args, **kwargs)
# restore value
if cached_value_existed:
waffle_namespace._cached_flags[namespaced_flag_name] = previous_value
elif namespaced_flag_name in waffle_namespace._cached_flags:
del waffle_namespace._cached_flags[namespaced_flag_name]
return wrapper
return real_decorator
"""
Common utility functions related to courses.
"""
from django import forms
from django.conf import settings
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import CourseKey
from xmodule.assetstore.assetmgr import AssetManager
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore
def course_image_url(course, image_key='course_image'):
......@@ -43,3 +47,37 @@ def create_course_image_thumbnail(course, dimensions):
_content, thumb_loc = contentstore().generate_thumbnail(course_image, dimensions=dimensions)
return StaticContent.serialize_asset_key_with_slash(thumb_loc)
def clean_course_id(model_form, is_required=True):
"""
Cleans and validates a course_id for use with a Django ModelForm.
Arguments:
model_form (form.ModelForm): The form that has a course_id.
is_required (Boolean): Default True. When True, validates that the
course_id is not empty. In all cases, when course_id is supplied,
validates that it is a valid course.
Returns:
(CourseKey) The cleaned and validated course_id as a CourseKey.
NOTE: This should ultimately replace all copies of "def clean_course_id".
"""
cleaned_id = model_form.cleaned_data["course_id"]
if not cleaned_id and not is_required:
return None
try:
course_key = CourseKey.from_string(cleaned_id)
except InvalidKeyError:
msg = u'Course id invalid. Entered course id was: "{0}."'.format(cleaned_id)
raise forms.ValidationError(msg)
if not modulestore().has_course(course_key):
msg = u'Course not found. Entered course id was: "{0}". '.format(course_key.to_deprecated_string())
raise forms.ValidationError(msg)
return course_key
"""
Unified course experience settings and helper methods.
"""
import waffle
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace
from request_cache.middleware import RequestCache
# Waffle flag to enable a single unified "Course" tab.
UNIFIED_COURSE_EXPERIENCE_FLAG = 'unified_course_experience'
# Waffle flag to enable the full screen course content view
# along with a unified course home page.
# Waffle flag to enable the full screen course content view along with a unified
# course home page.
# NOTE: This is the only legacy flag that does not use the namespace.
UNIFIED_COURSE_VIEW_FLAG = 'unified_course_view'
# Namespace for course experience waffle flags.
WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='course_experience')
# Waffle flag to enable a single unified "Course" tab.
UNIFIED_COURSE_TAB_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'unified_course_tab')
def default_course_url_name(request=None):
"""
......@@ -24,11 +28,16 @@ def default_course_url_name(request=None):
return 'courseware'
def course_home_url_name(request=None):
def course_home_url_name(course_key):
"""
Returns the course home page's URL name for the current user.
Arguments:
course_key (CourseKey): The course key for which the home url is being
requested.
"""
if waffle.flag_is_active(request or RequestCache.get_current_request(), UNIFIED_COURSE_EXPERIENCE_FLAG):
if UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key):
return 'openedx.course_experience.course_home'
else:
return 'info'
......@@ -5,7 +5,6 @@
<%!
import json
import waffle
from django.conf import settings
from django.utils.translation import ugettext as _
......@@ -15,7 +14,7 @@ from django.core.urlresolvers import reverse
from django_comment_client.permissions import has_permission
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
%>
<%block name="content">
......@@ -58,7 +57,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
<div class="page-content">
<div class="layout layout-1q3q">
<main class="layout-col layout-col-b">
% if welcome_message_fragment and waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG):
% if welcome_message_fragment and UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id):
<div class="section section-dates">
${HTML(welcome_message_fragment.body_html())}
</div>
......@@ -76,7 +75,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
${_("Bookmarks")}
</a>
</li>
% if waffle.flag_is_active(request, UNIFIED_COURSE_EXPERIENCE_FLAG):
% if UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id):
<li>
<a href="${reverse('openedx.course_experience.course_updates', args=[course.id])}">
<span class="icon fa fa-newspaper-o" aria-hidden="true"></span>
......
......@@ -4,17 +4,9 @@
<%namespace name='static' file='../static_content.html'/>
<%!
import json
from django.conf import settings
from django.utils.translation import ugettext as _
from django.template.defaultfilters import escapejs
from django.core.urlresolvers import reverse
from django_comment_client.permissions import has_permission
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
%>
<%block name="content">
......
......@@ -2,18 +2,16 @@
Tests for the course home page.
"""
from waffle.testutils import override_flag
from django.core.urlresolvers import reverse
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from openedx.features.course_experience import UNIFIED_COURSE_EXPERIENCE_FLAG
from .test_course_updates import create_course_update, remove_course_updates
TEST_PASSWORD = 'test'
......@@ -71,22 +69,13 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
remove_course_updates(self.course)
super(TestCourseHomePage, self).tearDown()
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
def test_unified_page(self):
"""
Verify the rendering of the unified page.
"""
url = course_home_url(self.course)
response = self.client.get(url)
self.assertContains(response, '<h2 class="hd hd-3 page-title">Test Course</h2>')
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=True)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
def test_welcome_message_when_unified(self):
url = course_home_url(self.course)
response = self.client.get(url)
self.assertContains(response, TEST_WELCOME_MESSAGE, status_code=200)
@override_flag(UNIFIED_COURSE_EXPERIENCE_FLAG, active=False)
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=False)
def test_welcome_message_when_not_unified(self):
url = course_home_url(self.course)
response = self.client.get(url)
......@@ -100,7 +89,7 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
with self.assertNumQueries(43):
with self.assertNumQueries(42):
with check_mongo_calls(5):
url = course_home_url(self.course)
self.client.get(url)
......@@ -124,7 +124,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed
with self.assertNumQueries(32):
with self.assertNumQueries(33):
with check_mongo_calls(4):
url = course_updates_url(self.course)
self.client.get(url)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment