From a0c1a09a1423c960a22b7987eac578ee8c79e218 Mon Sep 17 00:00:00 2001 From: ayub-khan Date: Mon, 30 Oct 2017 15:21:15 +0500 Subject: [PATCH 01/47] Created make targets for extract and push translations --- Makefile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Makefile b/Makefile index 4ad2e4a09b..6c9826d4b0 100644 --- a/Makefile +++ b/Makefile @@ -12,3 +12,11 @@ clean: -git clean -fdX tar xf $(PRIVATE_FILES) rm $(PRIVATE_FILES) + +extract_translations: + # Extract localizable strings from sources + paver i18n_extract + +push_translations: + # Push source strings to Transifex for translation + paver i18n_transifex_push From abe8b7e7b2839f3dfefa2978109555252edf0ce7 Mon Sep 17 00:00:00 2001 From: Awais Jibran Date: Mon, 30 Oct 2017 16:26:20 +0500 Subject: [PATCH 02/47] Import Cleanups more logging This PR is to add more logging when user clicks "Request Certificate" and gets error "Your certificate will be available when you pass the course." EDUCATOR-1616 --- lms/djangoapps/courseware/views/views.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 8b1f9d2fd5..6e421940c4 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -5,12 +5,11 @@ import json import logging import urllib from collections import OrderedDict, namedtuple -from datetime import datetime, timedelta +from datetime import datetime import analytics import shoppingcart import survey.views -import waffle from certificates import api as certs_api from certificates.models import CertificateStatuses from commerce.utils import EcommerceService @@ -69,7 +68,6 @@ from markupsafe import escape from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangoapps.catalog.utils import get_programs, get_programs_with_type -from openedx.core.djangoapps.certificates import api as auto_certs_api from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credit.api import ( get_credit_requirement_status, @@ -1343,10 +1341,19 @@ def generate_user_cert(request, course_id): return HttpResponseBadRequest(_("Course is not valid")) if not is_course_passed(student, course): + log.info(u"User %s has not passed the course: %s", student.username, course_id) return HttpResponseBadRequest(_("Your certificate will be available when you pass the course.")) certificate_status = certs_api.certificate_downloadable_status(student, course.id) + log.info( + u"User %s has requested for certificate in %s, current status: is_downloadable: %s, is_generating: %s", + student.username, + course_id, + certificate_status["is_downloadable"], + certificate_status["is_generating"], + ) + if certificate_status["is_downloadable"]: return HttpResponseBadRequest(_("Certificate has already been created.")) elif certificate_status["is_generating"]: From 3498cc4e4b824c52b0381ae991b9fe41692cbc4a Mon Sep 17 00:00:00 2001 From: Harry Rein Date: Mon, 30 Oct 2017 12:11:04 -0400 Subject: [PATCH 03/47] Responsive account settings page. --- lms/static/sass/views/_account-settings.scss | 56 ++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss index 77258baee6..fb67fd7afa 100644 --- a/lms/static/sass/views/_account-settings.scss +++ b/lms/static/sass/views/_account-settings.scss @@ -64,10 +64,10 @@ font-size: em(14); color: $gray; - padding: 5px 25px 23px; + padding: $baseline/4 $baseline*1.25 $baseline; display: inline-block; box-shadow: none; - border: none; + border-bottom: 4px solid transparent; border-radius: 0; background: transparent none; } @@ -84,11 +84,24 @@ &:hover, &:focus { text-decoration: none; - border-bottom: 4px solid $courseware-border-bottom-color !important; + border-bottom-color: $courseware-border-bottom-color; } &.active { - border-bottom: 4px solid $black-t3 !important; + border-bottom-color: theme-color("dark"); + } + } + } + + @include media-breakpoint-down(md) { + border-bottom-color: transparent; + + .account-nav { + display: flex; + border-bottom: none; + + .account-nav-link { + border-bottom: 4px solid theme-color("light"); } } } @@ -338,6 +351,41 @@ border-bottom: none; margin-bottom: ($baseline*2); } + + // Responsive behavior + @include media-breakpoint-down(md) { + .u-field-value { + width: 100%; + } + + .u-field-message { + width: 100%; + padding: $baseline/2 0; + + .u-field-message-notification { + position: relative; + padding: 0; + } + } + + .u-field-order { + display: flex; + flex-wrap: nowrap; + + u-field-order-number, + u-field-order-date, + u-field-order-price, + u-field-order-link, { + width: auto; + float: none; + flex-grow: 1; + + &:first-of-type { + flex-grow: 2; + } + } + } + } } .u-field-readonly .u-field-value { From 08d31e1a43f45755877768bae8e2f6325e0f7d2f Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Mon, 30 Oct 2017 13:19:02 -0400 Subject: [PATCH 04/47] Schedules: Tests for Course Update messages --- .../commands/tests/send_email_base.py | 92 ++++++++++--------- .../commands/tests/test_send_course_update.py | 37 ++++++++ .../tests/test_send_recurring_nudge.py | 5 +- .../tests/test_send_upgrade_reminder.py | 16 ++-- .../management/commands/tests/upsell_base.py | 2 +- .../core/djangoapps/schedules/resolvers.py | 4 +- openedx/core/djangoapps/schedules/tasks.py | 2 +- .../edx_ace/courseupdate/email/body.txt | 7 +- .../djangoapps/schedules/tests/factories.py | 2 + 9 files changed, 112 insertions(+), 55 deletions(-) create mode 100644 openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py index 718a80b8e3..dcd95def2a 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py @@ -23,15 +23,20 @@ from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -SITE_QUERY = 1 -ORG_DEADLINE_QUERY = 1 -SCHEDULES_QUERY = 1 -COURSE_MODES_QUERY = 1 -GLOBAL_DEADLINE_SWITCH_QUERY = 1 -COMMERCE_CONFIG_QUERY = 1 -NUM_QUERIES_NO_ORG_LIST = 1 +SITE_QUERY = 2 # django_site, site_configuration_siteconfiguration -NUM_QUERIES_NO_MATCHING_SCHEDULES = SITE_QUERY + SCHEDULES_QUERY +SCHEDULES_QUERY = 1 # schedules_schedule +COURSE_MODES_QUERY = 1 # course_modes_coursemode + +GLOBAL_DEADLINE_QUERY = 1 # courseware_dynamicupgradedeadlineconfiguration +ORG_DEADLINE_QUERY = 1 # courseware_orgdynamicupgradedeadlineconfiguration +COURSE_DEADLINE_QUERY = 1 # courseware_coursedynamicupgradedeadlineconfiguration +COMMERCE_CONFIG_QUERY = 1 # commerce_commerceconfiguration + +NUM_QUERIES_NO_MATCHING_SCHEDULES = ( + SITE_QUERY + + SCHEDULES_QUERY +) NUM_QUERIES_WITH_MATCHES = ( NUM_QUERIES_NO_MATCHING_SCHEDULES + @@ -40,8 +45,9 @@ NUM_QUERIES_WITH_MATCHES = ( NUM_QUERIES_FIRST_MATCH = ( NUM_QUERIES_WITH_MATCHES - + GLOBAL_DEADLINE_SWITCH_QUERY + + GLOBAL_DEADLINE_QUERY + ORG_DEADLINE_QUERY + + COURSE_DEADLINE_QUERY + COMMERCE_CONFIG_QUERY ) @@ -56,7 +62,8 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): ENABLED_CACHES = ['default'] - has_course_queries = False + queries_deadline_for_each_course = False + consolidates_emails_for_learner = False def setUp(self): super(ScheduleSendEmailTestBase, self).setUp() @@ -74,7 +81,11 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): current_day = _get_datetime_beginning_of_day(datetime.datetime.now(pytz.UTC)) offset = offset or self.expected_offsets[0] target_day = current_day + datetime.timedelta(days=offset) - return current_day, offset, target_day + if self.tested_resolver.schedule_date_field == 'upgrade_deadline': + upgrade_deadline = target_day + else: + upgrade_deadline = current_day + datetime.timedelta(days=7) + return current_day, offset, target_day, upgrade_deadline def _get_template_overrides(self): templates_override = deepcopy(settings.TEMPLATES) @@ -99,7 +110,7 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): @patch.object(tasks, 'ace') def test_resolver_send(self, mock_ace): - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, _ = self._get_dates() with patch.object(self.tested_task, 'apply_async') as mock_apply_async: self.tested_task.enqueue(self.site_config.site, current_day, offset) mock_apply_async.assert_any_call( @@ -117,18 +128,17 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): @patch.object(resolvers, 'set_custom_metric') def test_schedule_bin(self, schedule_count, mock_metric, mock_ace): with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, upgrade_deadline = self._get_dates() schedules = [ ScheduleFactory.create( start=target_day, - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, enrollment__course__self_paced=True, ) for _ in range(schedule_count) ] bins_in_use = frozenset((self._calculate_bin_for_user(s.enrollment.user)) for s in schedules) is_first_match = True - course_queries = len(set(s.enrollment.course.id for s in schedules)) if self.has_course_queries else 0 target_day_str = serialize(target_day) for b in range(self.tested_task.num_bins): @@ -139,14 +149,12 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): expected_queries = ( # Since this is the first match, we need to cache all of the config models, so we run a # query for each of those... - NUM_QUERIES_FIRST_MATCH + course_queries + NUM_QUERIES_FIRST_MATCH ) is_first_match = False else: expected_queries = NUM_QUERIES_WITH_MATCHES - expected_queries += NUM_QUERIES_NO_ORG_LIST - with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): self.tested_task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=target_day_str, day_offset=offset, bin_num=b, @@ -162,10 +170,10 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): self.assertFalse(mock_ace.send.called) def test_no_course_overview(self): - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, upgrade_deadline = self._get_dates() schedule = ScheduleFactory.create( start=target_day, - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, enrollment__course__self_paced=True, ) schedule.enrollment.course_id = CourseKey.from_string('edX/toy/Not_2012_Fall') @@ -239,24 +247,24 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): user1 = UserFactory.create(id=self.tested_task.num_bins) user2 = UserFactory.create(id=self.tested_task.num_bins * 2) - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, upgrade_deadline = self._get_dates() ScheduleFactory.create( - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, start=target_day, enrollment__course__org=filtered_org, enrollment__course__self_paced=True, enrollment__user=user1, ) ScheduleFactory.create( - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, start=target_day, enrollment__course__org=unfiltered_org, enrollment__course__self_paced=True, enrollment__user=user1, ) ScheduleFactory.create( - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, start=target_day, enrollment__course__org=unfiltered_org, enrollment__course__self_paced=True, @@ -274,11 +282,11 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): @ddt.data(True, False) def test_course_end(self, has_course_ended): user1 = UserFactory.create(id=self.tested_task.num_bins) - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, upgrade_deadline = self._get_dates() schedule = ScheduleFactory.create( start=target_day, - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, enrollment__course__self_paced=True, enrollment__user=user1, ) @@ -299,29 +307,31 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): self.assertTrue(mock_schedule_send.apply_async.called) @patch.object(tasks, 'ace') - def test_multiple_enrollments(self, mock_ace): + def test_multiple_target_schedules(self, mock_ace): user = UserFactory.create() - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, upgrade_deadline = self._get_dates() num_courses = 3 for course_index in range(num_courses): ScheduleFactory.create( start=target_day, - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, enrollment__course__self_paced=True, enrollment__user=user, enrollment__course__id=CourseKey.from_string('edX/toy/course{}'.format(course_index)) ) - course_queries = num_courses if self.has_course_queries else 0 - expected_query_count = NUM_QUERIES_FIRST_MATCH + course_queries + NUM_QUERIES_NO_ORG_LIST + additional_course_queries = num_courses - 1 if self.queries_deadline_for_each_course else 0 + expected_query_count = NUM_QUERIES_FIRST_MATCH + additional_course_queries with self.assertNumQueries(expected_query_count, table_blacklist=WAFFLE_TABLES): with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: self.tested_task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(user), )) - self.assertEqual(mock_schedule_send.apply_async.call_count, 1) - self.assertFalse(mock_ace.send.called) + + expected_call_count = 1 if self.consolidates_emails_for_learner else num_courses + self.assertEqual(mock_schedule_send.apply_async.call_count, expected_call_count) + self.assertFalse(mock_ace.send.called) @ddt.data(1, 10, 100) def test_templates(self, message_count): @@ -330,13 +340,13 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): self.clear_caches() def _assert_template_for_offset(self, offset, message_count): - current_day, offset, target_day = self._get_dates(offset) + current_day, offset, target_day, upgrade_deadline = self._get_dates(offset) user = UserFactory.create() for course_index in range(message_count): ScheduleFactory.create( start=target_day, - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, enrollment__course__self_paced=True, enrollment__user=user, enrollment__course__id=CourseKey.from_string('edX/toy/course{}'.format(course_index)) @@ -354,20 +364,20 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args) - num_expected_queries = NUM_QUERIES_NO_ORG_LIST + NUM_QUERIES_FIRST_MATCH - if self.has_course_queries: - num_expected_queries += message_count + num_expected_queries = NUM_QUERIES_FIRST_MATCH + if self.queries_deadline_for_each_course: + num_expected_queries += (message_count - 1) with self.assertNumQueries(num_expected_queries, table_blacklist=WAFFLE_TABLES): self.tested_task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(user), )) - self.assertEqual(len(sent_messages), 1) + num_expected_messages = 1 if self.consolidates_emails_for_learner else message_count + self.assertEqual(len(sent_messages), num_expected_messages) with self.assertNumQueries(2): - for args in sent_messages: - self.deliver_task(*args) + self.deliver_task(*sent_messages[0]) self.assertEqual(mock_channel.deliver.call_count, 1) for (_name, (_msg, email), _kwargs) in mock_channel.deliver.mock_calls: diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py new file mode 100644 index 0000000000..5dfae622c8 --- /dev/null +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py @@ -0,0 +1,37 @@ +from mock import patch +from unittest import skipUnless + +from django.conf import settings + +from openedx.core.djangoapps.schedules import resolvers, tasks +from openedx.core.djangoapps.schedules.management.commands import send_course_update as nudge +from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase +from openedx.core.djangoapps.schedules.management.commands.tests.upsell_base import ScheduleUpsellTestMixin +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +@skipUnless( + 'openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS, + "Can't test schedules if the app isn't installed", +) +class TestSendCourseUpdate(ScheduleUpsellTestMixin, ScheduleSendEmailTestBase): + __test__ = True + + # pylint: disable=protected-access + tested_resolver = resolvers.CourseUpdateResolver + tested_task = tasks.ScheduleCourseUpdate + deliver_task = tasks._course_update_schedule_send + tested_command = nudge.Command + deliver_config = 'deliver_course_update' + enqueue_config = 'enqueue_course_update' + expected_offsets = xrange(-7, -77, -7) + + queries_deadline_for_each_course = True + + def setUp(self): + super(TestSendCourseUpdate, self).setUp() + patcher = patch('openedx.core.djangoapps.schedules.resolvers.get_week_highlights') + mock_highlights = patcher.start() + mock_highlights.return_value = ['Highlight {}'.format(num + 1) for num in range(3)] + self.addCleanup(patcher.stop) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py index acab8d03f1..76a22b41b6 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py @@ -2,7 +2,7 @@ from unittest import skipUnless from django.conf import settings -from openedx.core.djangoapps.schedules import tasks +from openedx.core.djangoapps.schedules import resolvers, tasks from openedx.core.djangoapps.schedules.management.commands import send_recurring_nudge as nudge from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase from openedx.core.djangoapps.schedules.management.commands.tests.upsell_base import ScheduleUpsellTestMixin @@ -18,9 +18,12 @@ class TestSendRecurringNudge(ScheduleUpsellTestMixin, ScheduleSendEmailTestBase) __test__ = True # pylint: disable=protected-access + tested_resolver = resolvers.RecurringNudgeResolver tested_task = tasks.ScheduleRecurringNudge deliver_task = tasks._recurring_nudge_schedule_send tested_command = nudge.Command deliver_config = 'deliver_recurring_nudge' enqueue_config = 'enqueue_recurring_nudge' expected_offsets = (-3, -10) + + consolidates_emails_for_learner = True diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py index 4993dbe18c..62918d40b3 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py @@ -5,11 +5,11 @@ import ddt from django.conf import settings from edx_ace import Message from edx_ace.utils.date import serialize -from mock import Mock, patch +from mock import patch from opaque_keys.edx.locator import CourseLocator from course_modes.models import CourseMode -from openedx.core.djangoapps.schedules import tasks +from openedx.core.djangoapps.schedules import resolvers, tasks from openedx.core.djangoapps.schedules.management.commands import send_upgrade_reminder as reminder from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory @@ -27,6 +27,7 @@ LOG = logging.getLogger(__name__) class TestUpgradeReminder(ScheduleSendEmailTestBase): __test__ = True + tested_resolver = resolvers.UpgradeReminderResolver tested_task = tasks.ScheduleUpgradeReminder deliver_task = tasks._upgrade_reminder_schedule_send tested_command = reminder.Command @@ -34,15 +35,16 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): enqueue_config = 'enqueue_upgrade_reminder' expected_offsets = (2,) - has_course_queries = True + queries_deadline_for_each_course = True + consolidates_emails_for_learner = True @ddt.data(True, False) @patch.object(tasks, 'ace') def test_verified_learner(self, is_verified, mock_ace): user = UserFactory.create(id=self.tested_task.num_bins) - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, upgrade_deadline = self._get_dates() ScheduleFactory.create( - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, enrollment__course__self_paced=True, enrollment__user=user, enrollment__mode=CourseMode.VERIFIED if is_verified else CourseMode.AUDIT, @@ -56,12 +58,12 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): self.assertEqual(mock_ace.send.called, not is_verified) def test_filter_out_verified_schedules(self): - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, upgrade_deadline = self._get_dates() user = UserFactory.create() schedules = [ ScheduleFactory.create( - upgrade_deadline=target_day, + upgrade_deadline=upgrade_deadline, enrollment__user=user, enrollment__course__self_paced=True, enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(i)), diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py index 725c39ade9..1f74575cb2 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py @@ -29,7 +29,7 @@ class ScheduleUpsellTestMixin(object): def test_upsell(self, enable_config, testcase): DynamicUpgradeDeadlineConfiguration.objects.create(enabled=enable_config) - current_day, offset, target_day = self._get_dates() + current_day, offset, target_day, _ = self._get_dates() upgrade_deadline = None if testcase.set_deadline: upgrade_deadline = current_day + datetime.timedelta(days=testcase.deadline_offset) diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 3017868046..d2da8f31da 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -224,11 +224,11 @@ class InvalidContextError(Exception): pass -class ScheduleStartResolver(BinnedSchedulesBaseResolver): +class RecurringNudgeResolver(BinnedSchedulesBaseResolver): """ Send a message to all users whose schedule started at ``self.current_date`` + ``day_offset``. """ - log_prefix = 'Scheduled Nudge' + log_prefix = 'Recurring Nudge' schedule_date_field = 'start' num_bins = RECURRING_NUDGE_NUM_BINS diff --git a/openedx/core/djangoapps/schedules/tasks.py b/openedx/core/djangoapps/schedules/tasks.py index 1c732db888..9a8ba16a40 100644 --- a/openedx/core/djangoapps/schedules/tasks.py +++ b/openedx/core/djangoapps/schedules/tasks.py @@ -147,7 +147,7 @@ class ScheduleRecurringNudge(ScheduleMessageBaseTask): num_bins = resolvers.RECURRING_NUDGE_NUM_BINS enqueue_config_var = 'enqueue_recurring_nudge' log_prefix = RECURRING_NUDGE_LOG_PREFIX - resolver = resolvers.ScheduleStartResolver + resolver = resolvers.RecurringNudgeResolver async_send_task = _recurring_nudge_schedule_send def make_message_type(self, day_offset): diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.txt b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.txt index e9451139f0..afe80f2ef7 100644 --- a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.txt +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.txt @@ -4,8 +4,11 @@ Welcome to week {{ week_num }} of our {{ course_name }} course! Here is what you can look forward to learning this week: -{{ week_summary }} - {% endblocktrans %} +{% for highlight in week_highlights %} + * {{ highlight }} +{% endfor %} + + {% include "schedules/edx_ace/common/upsell_cta.txt"%} diff --git a/openedx/core/djangoapps/schedules/tests/factories.py b/openedx/core/djangoapps/schedules/tests/factories.py index 13c88e403d..affc67cc3b 100644 --- a/openedx/core/djangoapps/schedules/tests/factories.py +++ b/openedx/core/djangoapps/schedules/tests/factories.py @@ -25,3 +25,5 @@ class ScheduleConfigFactory(factory.DjangoModelFactory): deliver_recurring_nudge = True enqueue_upgrade_reminder = True deliver_upgrade_reminder = True + enqueue_course_update = True + deliver_course_update = True From da95676e1089ace6cfb4020a156ed157e1ef6a6b Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Mon, 30 Oct 2017 14:10:25 -0400 Subject: [PATCH 05/47] Schedules: rename test class variables that aren't test methods --- .../commands/tests/send_email_base.py | 54 +++++++++---------- .../commands/tests/test_send_course_update.py | 6 +-- .../tests/test_send_recurring_nudge.py | 6 +-- .../tests/test_send_upgrade_reminder.py | 14 ++--- .../management/commands/tests/upsell_base.py | 4 +- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py index dcd95def2a..db773e482d 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py @@ -75,13 +75,13 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) def _calculate_bin_for_user(self, user): - return user.id % self.tested_task.num_bins + return user.id % self.task.num_bins def _get_dates(self, offset=None): current_day = _get_datetime_beginning_of_day(datetime.datetime.now(pytz.UTC)) offset = offset or self.expected_offsets[0] target_day = current_day + datetime.timedelta(days=offset) - if self.tested_resolver.schedule_date_field == 'upgrade_deadline': + if self.resolver.schedule_date_field == 'upgrade_deadline': upgrade_deadline = target_day else: upgrade_deadline = current_day + datetime.timedelta(days=7) @@ -93,12 +93,12 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): return templates_override def test_command_task_binding(self): - self.assertEqual(self.tested_command.async_send_task, self.tested_task) + self.assertEqual(self.command.async_send_task, self.task) def test_handle(self): - with patch.object(self.tested_command, 'async_send_task') as mock_send: + with patch.object(self.command, 'async_send_task') as mock_send: test_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - self.tested_command().handle(date='2017-08-01', site_domain_name=self.site_config.site.domain) + self.command().handle(date='2017-08-01', site_domain_name=self.site_config.site.domain) for offset in self.expected_offsets: mock_send.enqueue.assert_any_call( @@ -111,14 +111,14 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): @patch.object(tasks, 'ace') def test_resolver_send(self, mock_ace): current_day, offset, target_day, _ = self._get_dates() - with patch.object(self.tested_task, 'apply_async') as mock_apply_async: - self.tested_task.enqueue(self.site_config.site, current_day, offset) + with patch.object(self.task, 'apply_async') as mock_apply_async: + self.task.enqueue(self.site_config.site, current_day, offset) mock_apply_async.assert_any_call( (self.site_config.site.id, serialize(target_day), offset, 0, None), retry=False, ) mock_apply_async.assert_any_call( - (self.site_config.site.id, serialize(target_day), offset, self.tested_task.num_bins - 1, None), + (self.site_config.site.id, serialize(target_day), offset, self.task.num_bins - 1, None), retry=False, ) self.assertFalse(mock_ace.send.called) @@ -127,7 +127,7 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): @patch.object(tasks, 'ace') @patch.object(resolvers, 'set_custom_metric') def test_schedule_bin(self, schedule_count, mock_metric, mock_ace): - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + with patch.object(self.task, 'async_send_task') as mock_schedule_send: current_day, offset, target_day, upgrade_deadline = self._get_dates() schedules = [ ScheduleFactory.create( @@ -141,7 +141,7 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): is_first_match = True target_day_str = serialize(target_day) - for b in range(self.tested_task.num_bins): + for b in range(self.task.num_bins): LOG.debug('Running bin %d', b) expected_queries = NUM_QUERIES_NO_MATCHING_SCHEDULES if b in bins_in_use: @@ -156,7 +156,7 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): expected_queries = NUM_QUERIES_WITH_MATCHES with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): - self.tested_task.apply(kwargs=dict( + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=target_day_str, day_offset=offset, bin_num=b, )) @@ -179,9 +179,9 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): schedule.enrollment.course_id = CourseKey.from_string('edX/toy/Not_2012_Fall') schedule.enrollment.save() - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: - for b in range(self.tested_task.num_bins): - self.tested_task.apply(kwargs=dict( + with patch.object(self.task, 'async_send_task') as mock_schedule_send: + for b in range(self.task.num_bins): + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, @@ -222,8 +222,8 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): ScheduleConfigFactory.create(**schedule_config_kwargs) current_datetime = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) - with patch.object(self.tested_task, 'apply_async') as mock_apply_async: - self.tested_task.enqueue(self.site_config.site, current_datetime, 3) + with patch.object(self.task, 'apply_async') as mock_apply_async: + self.task.enqueue(self.site_config.site, current_datetime, 3) if is_enabled: self.assertTrue(mock_apply_async.called) @@ -245,8 +245,8 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): for config in (this_config, other_config): ScheduleConfigFactory.create(site=config.site) - user1 = UserFactory.create(id=self.tested_task.num_bins) - user2 = UserFactory.create(id=self.tested_task.num_bins * 2) + user1 = UserFactory.create(id=self.task.num_bins) + user2 = UserFactory.create(id=self.task.num_bins * 2) current_day, offset, target_day, upgrade_deadline = self._get_dates() ScheduleFactory.create( @@ -271,8 +271,8 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): enrollment__user=user2, ) - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: - self.tested_task.apply(kwargs=dict( + with patch.object(self.task, 'async_send_task') as mock_schedule_send: + self.task.apply(kwargs=dict( site_id=this_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=0 )) @@ -281,7 +281,7 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): @ddt.data(True, False) def test_course_end(self, has_course_ended): - user1 = UserFactory.create(id=self.tested_task.num_bins) + user1 = UserFactory.create(id=self.task.num_bins) current_day, offset, target_day, upgrade_deadline = self._get_dates() schedule = ScheduleFactory.create( @@ -296,8 +296,8 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): schedule.enrollment.course.end = current_day + datetime.timedelta(days=end_date_offset) schedule.enrollment.course.save() - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: - self.tested_task.apply(kwargs=dict( + with patch.object(self.task, 'async_send_task') as mock_schedule_send: + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=0, )) @@ -323,8 +323,8 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): additional_course_queries = num_courses - 1 if self.queries_deadline_for_each_course else 0 expected_query_count = NUM_QUERIES_FIRST_MATCH + additional_course_queries with self.assertNumQueries(expected_query_count, table_blacklist=WAFFLE_TABLES): - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: - self.tested_task.apply(kwargs=dict( + with patch.object(self.task, 'async_send_task') as mock_schedule_send: + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(user), )) @@ -361,7 +361,7 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): sent_messages = [] with self.settings(TEMPLATES=self._get_template_overrides()): - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + with patch.object(self.task, 'async_send_task') as mock_schedule_send: mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args) num_expected_queries = NUM_QUERIES_FIRST_MATCH @@ -369,7 +369,7 @@ class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): num_expected_queries += (message_count - 1) with self.assertNumQueries(num_expected_queries, table_blacklist=WAFFLE_TABLES): - self.tested_task.apply(kwargs=dict( + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(user), )) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py index 5dfae622c8..88ac415810 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py @@ -19,10 +19,10 @@ class TestSendCourseUpdate(ScheduleUpsellTestMixin, ScheduleSendEmailTestBase): __test__ = True # pylint: disable=protected-access - tested_resolver = resolvers.CourseUpdateResolver - tested_task = tasks.ScheduleCourseUpdate + resolver = resolvers.CourseUpdateResolver + task = tasks.ScheduleCourseUpdate deliver_task = tasks._course_update_schedule_send - tested_command = nudge.Command + command = nudge.Command deliver_config = 'deliver_course_update' enqueue_config = 'enqueue_course_update' expected_offsets = xrange(-7, -77, -7) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py index 76a22b41b6..044c4d97d7 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py @@ -18,10 +18,10 @@ class TestSendRecurringNudge(ScheduleUpsellTestMixin, ScheduleSendEmailTestBase) __test__ = True # pylint: disable=protected-access - tested_resolver = resolvers.RecurringNudgeResolver - tested_task = tasks.ScheduleRecurringNudge + resolver = resolvers.RecurringNudgeResolver + task = tasks.ScheduleRecurringNudge deliver_task = tasks._recurring_nudge_schedule_send - tested_command = nudge.Command + command = nudge.Command deliver_config = 'deliver_recurring_nudge' enqueue_config = 'enqueue_recurring_nudge' expected_offsets = (-3, -10) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py index 62918d40b3..d323874e2a 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py @@ -27,10 +27,10 @@ LOG = logging.getLogger(__name__) class TestUpgradeReminder(ScheduleSendEmailTestBase): __test__ = True - tested_resolver = resolvers.UpgradeReminderResolver - tested_task = tasks.ScheduleUpgradeReminder + resolver = resolvers.UpgradeReminderResolver + task = tasks.ScheduleUpgradeReminder deliver_task = tasks._upgrade_reminder_schedule_send - tested_command = reminder.Command + command = reminder.Command deliver_config = 'deliver_upgrade_reminder' enqueue_config = 'enqueue_upgrade_reminder' expected_offsets = (2,) @@ -41,7 +41,7 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): @ddt.data(True, False) @patch.object(tasks, 'ace') def test_verified_learner(self, is_verified, mock_ace): - user = UserFactory.create(id=self.tested_task.num_bins) + user = UserFactory.create(id=self.task.num_bins) current_day, offset, target_day, upgrade_deadline = self._get_dates() ScheduleFactory.create( upgrade_deadline=upgrade_deadline, @@ -50,7 +50,7 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): enrollment__mode=CourseMode.VERIFIED if is_verified else CourseMode.AUDIT, ) - self.tested_task.apply(kwargs=dict( + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(user), )) @@ -73,10 +73,10 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): ] sent_messages = [] - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + with patch.object(self.task, 'async_send_task') as mock_schedule_send: mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args[1]) - self.tested_task.apply(kwargs=dict( + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(user), )) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py index 1f74575cb2..ade7ce9fd3 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py @@ -41,9 +41,9 @@ class ScheduleUpsellTestMixin(object): ) sent_messages = [] - with patch.object(self.tested_task, 'async_send_task') as mock_schedule_send: + with patch.object(self.task, 'async_send_task') as mock_schedule_send: mock_schedule_send.apply_async = lambda args, *_a, **_kw: sent_messages.append(args[1]) - self.tested_task.apply(kwargs=dict( + self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, bin_num=self._calculate_bin_for_user(schedule.enrollment.user), )) From 9a2e25c370fff0470cb7c9b6ebaf507aeeb97514 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Mon, 30 Oct 2017 14:33:25 -0400 Subject: [PATCH 06/47] Add derived/derived_dict_entry/derive_settings and tests. - Enables a method of deriving Django settings from other Django settings after all other Django settings are stable. --- openedx/core/lib/derived.py | 69 ++++++++++++++++++++++++++ openedx/core/lib/tests/test_derived.py | 44 ++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 openedx/core/lib/derived.py create mode 100644 openedx/core/lib/tests/test_derived.py diff --git a/openedx/core/lib/derived.py b/openedx/core/lib/derived.py new file mode 100644 index 0000000000..63497e6756 --- /dev/null +++ b/openedx/core/lib/derived.py @@ -0,0 +1,69 @@ +""" +Allows the registration of Django/Python settings that are derived from other settings +via callable methods/lambdas. The derivation time can be controlled to happen after all +other settings have been set. The derived setting can also be overridden by setting the +derived setting to an actual value. +""" +import six +import sys + +# Global list holding all settings which will be derived. +__DERIVED = [] + + +def derived(*settings): + """ + Registers settings which are derived from other settings. + Can be called multiple times to add more derived settings. + + Args: + settings (list): List of setting names to register. + """ + __DERIVED.extend(settings) + + +def derived_dict_entry(setting_dict, key): + """ + Registers a setting which is a dictionary and needs a derived value for a particular key. + Can be called multiple times to add more derived settings. + + Args: + setting_dict (str): Name of setting which contains a dictionary. + key (str): Name of key in the setting dictionary which will be derived. + """ + __DERIVED.append((setting_dict, key)) + + +def derive_settings(module_name): + """ + Derives all registered settings and sets them onto a particular module. + Skips deriving settings that are set to a value. + + Args: + module_name (str): Name of module to which the derived settings will be added. + """ + module = sys.modules[module_name] + for derived in __DERIVED: + if isinstance(derived, six.string_types): + setting = getattr(module, derived) + if callable(setting): + setting_val = setting(module) + setattr(module, derived, setting_val) + elif isinstance(derived, tuple): + # If a tuple, two elements are expected - else ignore. + if len(derived) == 2: + # Both elements are expected to be strings. + # The first string is the attribute which is expected to be a dictionary. + # The second string is a key in that dictionary containing a derived setting. + setting = getattr(module, derived[0])[derived[1]] + if callable(setting): + setting_val = setting(module) + getattr(module, derived[0]).update({derived[1]: setting_val}) + + +def clear_for_tests(): + """ + Clears all settings to be derived. For tests only. + """ + global __DERIVED + __DERIVED = [] diff --git a/openedx/core/lib/tests/test_derived.py b/openedx/core/lib/tests/test_derived.py new file mode 100644 index 0000000000..c42a20bdee --- /dev/null +++ b/openedx/core/lib/tests/test_derived.py @@ -0,0 +1,44 @@ +""" +Tests for derived.py +""" + +import sys +from unittest import TestCase +from openedx.core.lib.derived import derived, derive_settings, clear_for_tests + + +class TestDerivedSettings(TestCase): + """ + Test settings that are derived from other settings. + """ + def setUp(self): + super(TestDerivedSettings, self).setUp() + clear_for_tests() + self.module = sys.modules[__name__] + self.module.SIMPLE_VALUE = 'paneer' + self.module.DERIVED_VALUE = lambda settings: 'mutter ' + settings.SIMPLE_VALUE + self.module.ANOTHER_DERIVED_VALUE = lambda settings: settings.DERIVED_VALUE + ' with naan' + self.module.UNREGISTERED_DERIVED_VALUE = lambda settings: settings.SIMPLE_VALUE + ' is cheese' + derived('DERIVED_VALUE', 'ANOTHER_DERIVED_VALUE') + self.module.DICT_VALUE = {} + self.module.DICT_VALUE['test_key'] = lambda settings: settings.DERIVED_VALUE * 3 + derived(('DICT_VALUE', 'test_key')) + + def test_derived_settings_are_derived(self): + derive_settings(__name__) + self.assertEqual(self.module.DERIVED_VALUE, 'mutter paneer') + self.assertEqual(self.module.ANOTHER_DERIVED_VALUE, 'mutter paneer with naan') + + def test_unregistered_derived_settings(self): + derive_settings(__name__) + self.assertTrue(callable(self.module.UNREGISTERED_DERIVED_VALUE)) + + def test_derived_settings_overridden(self): + self.module.DERIVED_VALUE = 'aloo gobi' + derive_settings(__name__) + self.assertEqual(self.module.DERIVED_VALUE, 'aloo gobi') + self.assertEqual(self.module.ANOTHER_DERIVED_VALUE, 'aloo gobi with naan') + + def test_derived_dict_settings(self): + derive_settings(__name__) + self.assertEqual(self.module.DICT_VALUE['test_key'], 'mutter paneermutter paneermutter paneer') From b866f3562063d90ef6e213c684f3ba29d2dc8280 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Mon, 30 Oct 2017 14:36:06 -0400 Subject: [PATCH 07/47] Remove support for COMPREHENSIVE_THEME_DIR - all dirs must now go into COMPREHENSIVE_THEME_DIRS. Move comprehensive theming setup section out of startup.py and into settings files using new 'derived' functionality. Add 'derive_settings' at the end of all top-level Django settings files. Move validation of comprehensive theming settings into new apps.py theming file. Split theming code into code safe to run before settings are initialized -and- after settings are initialized. --- cms/envs/aws.py | 9 +- cms/envs/common.py | 32 ++- cms/envs/dev.py | 5 + cms/envs/test.py | 5 + cms/envs/test_static_optimized.py | 5 + cms/envs/yaml_config.py | 5 + cms/startup.py | 14 +- lms/envs/aws.py | 5 + lms/envs/common.py | 38 ++- lms/envs/dev.py | 5 + lms/envs/static.py | 5 + lms/envs/test.py | 7 +- lms/envs/test_static_optimized.py | 5 + lms/envs/yaml_config.py | 5 + lms/startup.py | 7 - openedx/core/djangoapps/theming/apps.py | 83 ++++++ openedx/core/djangoapps/theming/core.py | 38 --- openedx/core/djangoapps/theming/helpers.py | 264 +++++------------- .../core/djangoapps/theming/helpers_dirs.py | 165 +++++++++++ .../core/djangoapps/theming/helpers_static.py | 19 ++ .../management/commands/compile_sass.py | 2 +- .../theming/templatetags/theme_pipeline.py | 2 +- .../djangoapps/theming/tests/test_helpers.py | 16 +- 23 files changed, 455 insertions(+), 286 deletions(-) create mode 100644 openedx/core/djangoapps/theming/apps.py delete mode 100644 openedx/core/djangoapps/theming/core.py create mode 100644 openedx/core/djangoapps/theming/helpers_dirs.py create mode 100644 openedx/core/djangoapps/theming/helpers_static.py diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 991e2212e6..855543d463 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -15,6 +15,7 @@ import json from .common import * +from openedx.core.lib.derived import derive_settings from openedx.core.lib.logsettings import get_logger_config import os @@ -202,10 +203,6 @@ COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) ASSET_IGNORE_REGEX = ENV_TOKENS.get('ASSET_IGNORE_REGEX', ASSET_IGNORE_REGEX) -# following setting is for backward compatibility -if ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR', None): - COMPREHENSIVE_THEME_DIR = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR') - COMPREHENSIVE_THEME_DIRS = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIRS', COMPREHENSIVE_THEME_DIRS) or [] # COMPREHENSIVE_THEME_LOCALE_PATHS contain the paths to themes locale directories e.g. @@ -534,3 +531,7 @@ PARENTAL_CONSENT_AGE_LIMIT = ENV_TOKENS.get( # Allow extra middleware classes to be added to the app through configuration. MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/cms/envs/common.py b/cms/envs/common.py index 2f6e91d02b..a0d36d2f8f 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -50,7 +50,7 @@ import lms.envs.common from lms.envs.common import ( USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, PLATFORM_DESCRIPTION, BUGS_EMAIL, DOC_STORE_CONFIG, DATA_DIR, ALL_LANGUAGES, WIKI_ENABLED, update_module_store_settings, ASSET_IGNORE_REGEX, - PARENTAL_CONSENT_AGE_LIMIT, COMPREHENSIVE_THEME_DIRS, REGISTRATION_EMAIL_PATTERNS_ALLOWED, + PARENTAL_CONSENT_AGE_LIMIT, REGISTRATION_EMAIL_PATTERNS_ALLOWED, # The following PROFILE_IMAGE_* settings are included as they are # indirectly accessed through the email opt-in API, which is # technically accessible through the CMS via legacy URLs. @@ -81,6 +81,8 @@ from lms.envs.common import ( # Enable or disable theming ENABLE_COMPREHENSIVE_THEMING, + COMPREHENSIVE_THEME_LOCALE_PATHS, + COMPREHENSIVE_THEME_DIRS, # constants for redirects app REDIRECT_CACHE_TIMEOUT, @@ -113,6 +115,10 @@ from lms.envs.common import ( # Video Image settings VIDEO_IMAGE_SETTINGS, VIDEO_TRANSCRIPTS_SETTINGS, + + # Methods to derive settings + _make_main_mako_templates, + _make_locale_paths, ) from path import Path as path from warnings import simplefilter @@ -121,7 +127,12 @@ from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin from cms.lib.xblock.authoring_mixin import AuthoringMixin import dealer.git from xmodule.modulestore.edit_info import EditInfoMixin +from openedx.core.djangoapps.theming.helpers_dirs import ( + get_themes_unchecked, + get_theme_base_dirs_from_settings +) from openedx.core.lib.license import LicenseMixin +from openedx.core.lib.derived import derived, derived_dict_entry ############################ FEATURE CONFIGURATION ############################# @@ -300,7 +311,7 @@ GEOIPV6_PATH = REPO_ROOT / "common/static/data/geoip/GeoIPv6.dat" import tempfile MAKO_MODULE_DIR = os.path.join(tempfile.gettempdir(), 'mako_cms') MAKO_TEMPLATES = {} -MAKO_TEMPLATES['main'] = [ +MAIN_MAKO_TEMPLATES_BASE = [ PROJECT_ROOT / 'templates', COMMON_ROOT / 'templates', COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates', @@ -310,9 +321,10 @@ MAKO_TEMPLATES['main'] = [ OPENEDX_ROOT / 'core' / 'lib' / 'license' / 'templates', CMS_ROOT / 'djangoapps' / 'pipeline_js' / 'templates', ] +MAKO_TEMPLATES['lms.main'] = lms.envs.common.MAIN_MAKO_TEMPLATES_BASE -for namespace, template_dirs in lms.envs.common.MAKO_TEMPLATES.iteritems(): - MAKO_TEMPLATES['lms.' + namespace] = template_dirs +MAKO_TEMPLATES['main'] = _make_main_mako_templates +derived_dict_entry('MAKO_TEMPLATES', 'main') # Django templating TEMPLATES = [ @@ -321,7 +333,7 @@ TEMPLATES = [ # Don't look for template source files inside installed applications. 'APP_DIRS': False, # Instead, look for template source files in these dirs. - 'DIRS': MAKO_TEMPLATES['main'], + 'DIRS': MAIN_MAKO_TEMPLATES_BASE, # Options specific to this backend. 'OPTIONS': { 'loaders': ( @@ -601,8 +613,9 @@ USE_L10N = True STATICI18N_ROOT = PROJECT_ROOT / "static" -# Localization strings (e.g. django.po) are under this directory -LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # edx-platform/conf/locale/ +# Localization strings (e.g. django.po) are under these directories +LOCALE_PATHS = _make_locale_paths +derived('LOCALE_PATHS') # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -959,7 +972,7 @@ INSTALLED_APPS = [ 'webpack_loader', # Theming - 'openedx.core.djangoapps.theming', + 'openedx.core.djangoapps.theming.apps.ThemingConfig', # Site configuration for theming and behavioral modification 'openedx.core.djangoapps.site_configuration', @@ -1370,9 +1383,6 @@ AFFILIATE_COOKIE_NAME = 'affiliate_id' HELP_TOKENS_INI_FILE = REPO_ROOT / "cms" / "envs" / "help_tokens.ini" -# Theme directory locale paths -COMPREHENSIVE_THEME_LOCALE_PATHS = [] - # This is required for the migrations in oauth_dispatch.models # otherwise it fails saying this attribute is not present in Settings # Although Studio does not exable OAuth2 Provider capability, the new approach diff --git a/cms/envs/dev.py b/cms/envs/dev.py index ae4efe5ec4..20dd68d6a5 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -6,6 +6,7 @@ This config file runs the simplest dev environment""" # pylint: disable=wildcard-import, unused-wildcard-import from .common import * +from openedx.core.lib.derived import derive_settings from openedx.core.lib.logsettings import get_logger_config # import settings from LMS for consistent behavior with CMS @@ -179,3 +180,7 @@ try: from .private import * # pylint: disable=import-error except ImportError: pass + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/cms/envs/test.py b/cms/envs/test.py index 5657ecd1b8..1bcd33372b 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -24,6 +24,7 @@ from path import Path as path from warnings import filterwarnings, simplefilter from uuid import uuid4 from util.db import NoOpMigrationModules +from openedx.core.lib.derived import derive_settings # import settings from LMS for consistent behavior with CMS # pylint: disable=unused-import @@ -360,3 +361,7 @@ VIDEO_TRANSCRIPTS_SETTINGS = dict( ), DIRECTORY_PREFIX='video-transcripts/', ) + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/cms/envs/test_static_optimized.py b/cms/envs/test_static_optimized.py index ddff762b57..2874ee494d 100644 --- a/cms/envs/test_static_optimized.py +++ b/cms/envs/test_static_optimized.py @@ -12,6 +12,7 @@ from the same directory. # Start with the common settings from .common import * # pylint: disable=wildcard-import, unused-wildcard-import +from openedx.core.lib.derived import derive_settings # Use an in-memory database since this settings file is only used for updating assets DATABASES = { @@ -46,3 +47,7 @@ WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json" # 1. Uglify is by far the slowest part of the build process # 2. Having full source code makes debugging tests easier for developers os.environ['REQUIRE_BUILD_PROFILE_OPTIMIZE'] = 'none' + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/cms/envs/yaml_config.py b/cms/envs/yaml_config.py index f8944b3e8b..1def36ef06 100644 --- a/cms/envs/yaml_config.py +++ b/cms/envs/yaml_config.py @@ -17,6 +17,7 @@ defined in the environment: import yaml from .common import * +from openedx.core.lib.derived import derive_settings from openedx.core.lib.logsettings import get_logger_config from util.config_parse import convert_tokens import os @@ -264,3 +265,7 @@ if FEATURES.get('CUSTOM_COURSES_EDX'): # Allow extra middleware classes to be added to the app through configuration. MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/cms/startup.py b/cms/startup.py index 29b11e1716..4ed7cf4490 100644 --- a/cms/startup.py +++ b/cms/startup.py @@ -8,17 +8,16 @@ from django.conf import settings import cms.lib.xblock.runtime import xmodule.x_module from openedx.core.djangoapps.monkey_patch import django_db_models_options -from openedx.core.djangoapps.theming.core import enable_theming -from openedx.core.djangoapps.theming.helpers import is_comprehensive_theming_enabled from openedx.core.lib.django_startup import autostartup -from openedx.core.lib.xblock_utils import xblock_local_resource_url -from openedx.core.release import doc_version -from startup_configurations.validate_config import validate_cms_config # Force settings to run so that the python path is modified settings.INSTALLED_APPS # pylint: disable=pointless-statement +from openedx.core.lib.xblock_utils import xblock_local_resource_url +from openedx.core.release import doc_version +from startup_configurations.validate_config import validate_cms_config + def run(): """ @@ -29,11 +28,6 @@ def run(): """ django_db_models_options.patch() - # Comprehensive theming needs to be set up before django startup, - # because modifying django template paths after startup has no effect. - if is_comprehensive_theming_enabled(): - enable_theming() - django.setup() autostartup() diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 8b61c3d70c..7be1753a7c 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -25,6 +25,7 @@ import warnings import dateutil from .common import * +from openedx.core.lib.derived import derive_settings from openedx.core.lib.logsettings import get_logger_config import os @@ -1068,3 +1069,7 @@ ACE_ROUTING_KEY = ENV_TOKENS.get('ACE_ROUTING_KEY', ACE_ROUTING_KEY) # Allow extra middleware classes to be added to the app through configuration. MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/lms/envs/common.py b/lms/envs/common.py index bbbf537e19..1517e8be6d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -39,9 +39,13 @@ from warnings import simplefilter from django.utils.translation import ugettext_lazy as _ from .discussionsettings import * +from openedx.core.djangoapps.theming.helpers_dirs import ( + get_themes_unchecked, + get_theme_base_dirs_from_settings +) +from openedx.core.lib.derived import derived, derived_dict_entry from xmodule.modulestore.modulestore_settings import update_module_store_settings from xmodule.modulestore.edit_info import EditInfoMixin -from openedx.core.lib.license import LicenseMixin from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin ################################### FEATURES ################################### @@ -530,7 +534,7 @@ OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' import tempfile MAKO_MODULE_DIR = os.path.join(tempfile.gettempdir(), 'mako_lms') MAKO_TEMPLATES = {} -MAKO_TEMPLATES['main'] = [ +MAIN_MAKO_TEMPLATES_BASE = [ PROJECT_ROOT / 'templates', COMMON_ROOT / 'templates', COMMON_ROOT / 'lib' / 'capa' / 'capa' / 'templates', @@ -540,6 +544,20 @@ MAKO_TEMPLATES['main'] = [ OPENEDX_ROOT / 'core' / 'lib' / 'license' / 'templates', ] + +def _make_main_mako_templates(settings): + """ + Derives the final MAKO_TEMPLATES['main'] setting from other settings. + """ + if settings.ENABLE_COMPREHENSIVE_THEMING: + themes_dirs = get_theme_base_dirs_from_settings(settings.COMPREHENSIVE_THEME_DIRS) + for theme in get_themes_unchecked(themes_dirs, PROJECT_ROOT): + if theme.themes_base_dir not in settings.MAIN_MAKO_TEMPLATES_BASE: + settings.MAIN_MAKO_TEMPLATES_BASE.insert(0, theme.themes_base_dir) + return settings.MAIN_MAKO_TEMPLATES_BASE +MAKO_TEMPLATES['main'] = _make_main_mako_templates +derived_dict_entry('MAKO_TEMPLATES', 'main') + # Django templating TEMPLATES = [ { @@ -1015,8 +1033,18 @@ USE_L10N = True STATICI18N_ROOT = PROJECT_ROOT / "static" STATICI18N_OUTPUT_DIR = "js/i18n" -# Localization strings (e.g. django.po) are under this directory -LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # edx-platform/conf/locale/ + +# Localization strings (e.g. django.po) are under these directories +def _make_locale_paths(settings): + locale_paths = [settings.REPO_ROOT + '/conf/locale'] # edx-platform/conf/locale/ + if settings.ENABLE_COMPREHENSIVE_THEMING: + # Add locale paths to settings for comprehensive theming. + for locale_path in settings.COMPREHENSIVE_THEME_LOCALE_PATHS: + locale_paths += (path(locale_path), ) + return locale_paths +LOCALE_PATHS = _make_locale_paths +derived('LOCALE_PATHS') + # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -2018,7 +2046,7 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.contentserver', # Theming - 'openedx.core.djangoapps.theming', + 'openedx.core.djangoapps.theming.apps.ThemingConfig', # Site configuration for theming and behavioral modification 'openedx.core.djangoapps.site_configuration', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 6dd5e729e4..fad2d4652f 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -13,6 +13,7 @@ sessions. Assumes structure: # pylint: disable=wildcard-import, unused-wildcard-import from .common import * +from openedx.core.lib.derived import derive_settings DEBUG = True TEMPLATE_DEBUG = True @@ -270,3 +271,7 @@ try: from .private import * # pylint: disable=import-error except ImportError: pass + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/lms/envs/static.py b/lms/envs/static.py index 9f9749a953..3a02a8140d 100644 --- a/lms/envs/static.py +++ b/lms/envs/static.py @@ -13,6 +13,7 @@ sessions. Assumes structure: # pylint: disable=wildcard-import, unused-wildcard-import from .common import * +from openedx.core.lib.derived import derive_settings from openedx.core.lib.logsettings import get_logger_config STATIC_GRAB = True @@ -69,3 +70,7 @@ FILE_UPLOAD_HANDLERS = [ 'django.core.files.uploadhandler.MemoryFileUploadHandler', 'django.core.files.uploadhandler.TemporaryFileUploadHandler', ] + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/lms/envs/test.py b/lms/envs/test.py index 83eb021ad4..af913e7533 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -25,6 +25,7 @@ from uuid import uuid4 from warnings import filterwarnings, simplefilter from util.db import NoOpMigrationModules +from openedx.core.lib.derived import derive_settings from openedx.core.lib.tempdir import mkdtemp_clean # This patch disables the commit_on_success decorator during tests @@ -506,7 +507,7 @@ MICROSITE_LOGISTRATION_HOSTNAME = 'logistration.testserver' TEST_THEME = COMMON_ROOT / "test" / "test-theme" # add extra template directory for test-only templates -MAKO_TEMPLATES['main'].extend([ +MAIN_MAKO_TEMPLATES_BASE.extend([ COMMON_ROOT / 'test' / 'templates', COMMON_ROOT / 'test' / 'test_sites', REPO_ROOT / 'openedx' / 'core' / 'djangolib' / 'tests' / 'templates', @@ -605,3 +606,7 @@ ENTERPRISE_CONSENT_API_URL = 'http://enterprise.example.com/consent/api/v1/' ACTIVATION_EMAIL_FROM_ADDRESS = 'test_activate@edx.org' TEMPLATES[0]['OPTIONS']['debug'] = True + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/lms/envs/test_static_optimized.py b/lms/envs/test_static_optimized.py index 618e39a438..25a44eb9e4 100644 --- a/lms/envs/test_static_optimized.py +++ b/lms/envs/test_static_optimized.py @@ -12,6 +12,7 @@ from the same directory. # Start with the common settings from .common import * # pylint: disable=wildcard-import, unused-wildcard-import +from openedx.core.lib.derived import derive_settings # Use an in-memory database since this settings file is only used for updating assets DATABASES = { @@ -59,3 +60,7 @@ WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json" # 1. Uglify is by far the slowest part of the build process # 2. Having full source code makes debugging tests easier for developers os.environ['REQUIRE_BUILD_PROFILE_OPTIMIZE'] = 'none' + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/lms/envs/yaml_config.py b/lms/envs/yaml_config.py index 50c633764d..83ed8b56b7 100644 --- a/lms/envs/yaml_config.py +++ b/lms/envs/yaml_config.py @@ -16,6 +16,7 @@ defined in the environment: import yaml from .common import * +from openedx.core.lib.derived import derive_settings from openedx.core.lib.logsettings import get_logger_config from util.config_parse import convert_tokens import os @@ -329,3 +330,7 @@ CREDENTIALS_GENERATION_ROUTING_KEY = HIGH_PRIORITY_QUEUE # Allow extra middleware classes to be added to the app through configuration. MIDDLEWARE_CLASSES.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) + +########################## Derive Any Derived Settings ####################### + +derive_settings(__name__) diff --git a/lms/startup.py b/lms/startup.py index 05b4ff18f9..d5836dad34 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -21,8 +21,6 @@ import xmodule.x_module import lms_xblock.runtime from startup_configurations.validate_config import validate_lms_config -from openedx.core.djangoapps.theming.core import enable_theming -from openedx.core.djangoapps.theming.helpers import is_comprehensive_theming_enabled from microsite_configuration import microsite @@ -38,11 +36,6 @@ def run(): """ django_db_models_options.patch() - # Comprehensive theming needs to be set up before django startup, - # because modifying django template paths after startup has no effect. - if is_comprehensive_theming_enabled(): - enable_theming() - # We currently use 2 template rendering engines, mako and django_templates, # and one of them (django templates), requires the directories be added # before the django.setup(). diff --git a/openedx/core/djangoapps/theming/apps.py b/openedx/core/djangoapps/theming/apps.py new file mode 100644 index 0000000000..a02cc6971b --- /dev/null +++ b/openedx/core/djangoapps/theming/apps.py @@ -0,0 +1,83 @@ + +import os +import six +from django.apps import AppConfig +from django.conf import settings +from django.core.checks import Error, Tags, register + + +class ThemingConfig(AppConfig): + name = 'openedx.core.djangoapps.theming' + verbose_name = "Theming" + + +@register(Tags.compatibility) +def check_comprehensive_theme_settings(app_configs, **kwargs): + """ + Checks the comprehensive theming theme directory settings. + + Raises compatibility Errors upon: + - COMPREHENSIVE_THEME_DIRS is not a list + - theme dir path is not a string + - theme dir path is not an absolute path + - path specified in COMPREHENSIVE_THEME_DIRS does not exist + + Returns: + List of any Errors. + """ + if not getattr(settings, "ENABLE_COMPREHENSIVE_THEMING"): + # Only perform checks when comprehensive theming is enabled. + return [] + + errors = [] + + # COMPREHENSIVE_THEME_DIR is no longer supported - support has been removed. + if hasattr(settings, "COMPREHENSIVE_THEME_DIR"): + theme_dir = settings.COMPREHENSIVE_THEME_DIR + + errors.append( + Error( + "COMPREHENSIVE_THEME_DIR setting has been removed in favor of COMPREHENSIVE_THEME_DIRS.", + hint='Transfer the COMPREHENSIVE_THEME_DIR value to COMPREHENSIVE_THEME_DIRS.', + obj=theme_dir, + id='openedx.core.djangoapps.theming.E001', + ) + ) + + if hasattr(settings, "COMPREHENSIVE_THEME_DIRS"): + theme_dirs = settings.COMPREHENSIVE_THEME_DIRS + + if not isinstance(theme_dirs, list): + errors.append( + Error( + "COMPREHENSIVE_THEME_DIRS must be a list.", + obj=theme_dirs, + id='openedx.core.djangoapps.theming.E004', + ) + ) + if not all([isinstance(theme_dir, six.string_types) for theme_dir in theme_dirs]): + errors.append( + Error( + "COMPREHENSIVE_THEME_DIRS must contain only strings.", + obj=theme_dirs, + id='openedx.core.djangoapps.theming.E005', + ) + ) + if not all([theme_dir.startswith("/") for theme_dir in theme_dirs]): + errors.append( + Error( + "COMPREHENSIVE_THEME_DIRS must contain only absolute paths to themes dirs.", + obj=theme_dirs, + id='openedx.core.djangoapps.theming.E006', + ) + ) + if not all([os.path.isdir(theme_dir) for theme_dir in theme_dirs]): + errors.append( + Error( + "COMPREHENSIVE_THEME_DIRS must contain valid paths.", + obj=theme_dirs, + id='openedx.core.djangoapps.theming.E007', + ) + ) + + return errors diff --git a/openedx/core/djangoapps/theming/core.py b/openedx/core/djangoapps/theming/core.py deleted file mode 100644 index d2e68c7dd1..0000000000 --- a/openedx/core/djangoapps/theming/core.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Core logic for Comprehensive Theming. -""" -from logging import getLogger - -from django.conf import settings -from path import Path as path - -from .helpers import get_themes - -logger = getLogger(__name__) # pylint: disable=invalid-name - - -def enable_theming(): - """ - Add directories and relevant paths to settings for comprehensive theming. - """ - # Deprecated Warnings - if hasattr(settings, "COMPREHENSIVE_THEME_DIR"): - logger.warning( - "\033[93m \nDeprecated: " - "\n\tCOMPREHENSIVE_THEME_DIR setting has been deprecated in favor of COMPREHENSIVE_THEME_DIRS.\033[00m" - ) - - for theme in get_themes(): - if theme.themes_base_dir not in settings.MAKO_TEMPLATES['main']: - settings.MAKO_TEMPLATES['main'].insert(0, theme.themes_base_dir) - - _add_theming_locales() - - -def _add_theming_locales(): - """ - Add locale paths to settings for comprehensive theming. - """ - theme_locale_paths = settings.COMPREHENSIVE_THEME_LOCALE_PATHS - for locale_path in theme_locale_paths: - settings.LOCALE_PATHS += (path(locale_path), ) # pylint: disable=no-member diff --git a/openedx/core/djangoapps/theming/helpers.py b/openedx/core/djangoapps/theming/helpers.py index d178c81111..69140b9e6d 100644 --- a/openedx/core/djangoapps/theming/helpers.py +++ b/openedx/core/djangoapps/theming/helpers.py @@ -5,12 +5,18 @@ import os import re from logging import getLogger -from django.conf import ImproperlyConfigured, settings -from django.contrib.staticfiles.storage import staticfiles_storage +from django.conf import settings from path import Path from microsite_configuration import microsite from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming.helpers_dirs import ( + get_theme_base_dirs_from_settings, + get_themes_unchecked, + get_theme_dirs, + get_project_root_name_from_settings, + Theme +) from request_cache.middleware import RequestCache logger = getLogger(__name__) # pylint: disable=invalid-name @@ -101,6 +107,23 @@ def get_all_theme_template_dirs(): return template_paths +def get_project_root_name(): + """ + Return root name for the current project + + Example: + >> get_project_root_name() + 'lms' + # from studio + >> get_project_root_name() + 'cms' + + Returns: + (str): component name of platform e.g lms, cms + """ + return get_project_root_name_from_settings(settings.PROJECT_ROOT) + + def strip_site_theme_templates_path(uri): """ Remove site template theme path from the uri. @@ -189,6 +212,7 @@ def get_current_theme(): name=site_theme.theme_dir_name, theme_dir_name=site_theme.theme_dir_name, themes_base_dir=get_theme_base_dir(site_theme.theme_dir_name), + project_root=get_project_root_name() ) except ValueError as error: # Log exception message and return None, so that open source theme is used instead @@ -232,78 +256,64 @@ def get_theme_base_dir(theme_dir_name, suppress_error=False): )) -def get_project_root_name(): +def theme_exists(theme_name, themes_dir=None): """ - Return root name for the current project + Returns True if a theme exists with the specified name. + """ + for theme in get_themes(themes_dir=themes_dir): + if theme.theme_dir_name == theme_name: + return True + return False + + +def get_themes(themes_dir=None): + """ + get a list of all themes known to the system. + + Args: + themes_dir (str): (Optional) Path to themes base directory + Returns: + list of themes known to the system. + """ + if not is_comprehensive_theming_enabled(): + return [] + if themes_dir is None: + themes_dir = get_theme_base_dirs_unchecked() + return get_themes_unchecked(themes_dir, settings.PROJECT_ROOT) + + +def get_theme_base_dirs_unchecked(): + """ + Return base directories that contains all the themes. Example: - >> get_project_root_name() - 'lms' - # from studio - >> get_project_root_name() - 'cms' + >> get_theme_base_dirs_unchecked() + ['/edx/app/ecommerce/ecommerce/themes'] Returns: - (str): component name of platform e.g lms, cms + (List of Paths): Base theme directory paths """ - root = Path(settings.PROJECT_ROOT) - if root.name == "": - root = root.parent - return root.name + theme_dirs = getattr(settings, "COMPREHENSIVE_THEME_DIRS", None) + + return get_theme_base_dirs_from_settings(theme_dirs) def get_theme_base_dirs(): """ - Return base directory that contains all the themes. - - Raises: - ImproperlyConfigured - exception is raised if - 1 - COMPREHENSIVE_THEME_DIRS is not a list - 1 - theme dir path is not a string - 2 - theme dir path is not an absolute path - 3 - path specified in COMPREHENSIVE_THEME_DIRS does not exist + Return base directories that contains all the themes. + Ensures comprehensive theming is enabled. Example: >> get_theme_base_dirs() ['/edx/app/ecommerce/ecommerce/themes'] Returns: - (Path): Base theme directory path + (List of Paths): Base theme directory paths """ # Return an empty list if theming is disabled if not is_comprehensive_theming_enabled(): return [] - - theme_base_dirs = [] - - # Legacy code for COMPREHENSIVE_THEME_DIR backward compatibility - if hasattr(settings, "COMPREHENSIVE_THEME_DIR"): - theme_dir = settings.COMPREHENSIVE_THEME_DIR - - if not isinstance(theme_dir, basestring): - raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be a string.") - if not theme_dir.startswith("/"): - raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be an absolute paths to themes dir.") - if not os.path.isdir(theme_dir): - raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIR must be a valid path.") - - theme_base_dirs.append(Path(theme_dir)) - - if hasattr(settings, "COMPREHENSIVE_THEME_DIRS"): - theme_dirs = settings.COMPREHENSIVE_THEME_DIRS - - if not isinstance(theme_dirs, list): - raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must be a list.") - if not all([isinstance(theme_dir, basestring) for theme_dir in theme_dirs]): - raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must contain only strings.") - if not all([theme_dir.startswith("/") for theme_dir in theme_dirs]): - raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must contain only absolute paths to themes dirs.") - if not all([os.path.isdir(theme_dir) for theme_dir in theme_dirs]): - raise ImproperlyConfigured("COMPREHENSIVE_THEME_DIRS must contain valid paths.") - - theme_base_dirs.extend([Path(theme_dir) for theme_dir in theme_dirs]) - - return theme_base_dirs + return get_theme_base_dirs_unchecked() def is_comprehensive_theming_enabled(): @@ -326,149 +336,3 @@ def is_comprehensive_theming_enabled(): return False return settings.ENABLE_COMPREHENSIVE_THEMING - - -def get_static_file_url(asset): - """ - Returns url of the themed asset if asset is not themed than returns the default asset url. - - Example: - >> get_static_file_url('css/lms-main-v1.css') - '/static/red-theme/css/lms-main-v1.css' - - Parameters: - asset (str): asset's path relative to the static files directory - - Returns: - (str): static asset's url - """ - return staticfiles_storage.url(asset) - - -def get_themes(themes_dir=None): - """ - get a list of all themes known to the system. - - Args: - themes_dir (str): (Optional) Path to themes base directory - Returns: - list of themes known to the system. - """ - if not is_comprehensive_theming_enabled(): - return [] - - themes_dirs = [Path(themes_dir)] if themes_dir else get_theme_base_dirs() - # pick only directories and discard files in themes directory - themes = [] - for themes_dir in themes_dirs: - themes.extend([Theme(name, name, themes_dir) for name in get_theme_dirs(themes_dir)]) - - return themes - - -def theme_exists(theme_name, themes_dir=None): - """ - Returns True if a theme exists with the specified name. - """ - for theme in get_themes(themes_dir=themes_dir): - if theme.theme_dir_name == theme_name: - return True - return False - - -def get_theme_dirs(themes_dir=None): - """ - Returns theme dirs in given dirs - Args: - themes_dir (Path): base dir that contains themes. - """ - return [_dir for _dir in os.listdir(themes_dir) if is_theme_dir(themes_dir / _dir)] - - -def is_theme_dir(_dir): - """ - Returns true if given dir contains theme overrides. - A theme dir must have subdirectory 'lms' or 'cms' or both. - - Args: - _dir: directory path to check for a theme - - Returns: - Returns true if given dir is a theme directory. - """ - theme_sub_directories = {'lms', 'cms'} - return bool(os.path.isdir(_dir) and theme_sub_directories.intersection(os.listdir(_dir))) - - -class Theme(object): - """ - class to encapsulate theme related information. - """ - name = '' - theme_dir_name = '' - themes_base_dir = None - - def __init__(self, name='', theme_dir_name='', themes_base_dir=None): - """ - init method for Theme - - Args: - name: name if the theme - theme_dir_name: directory name of the theme - themes_base_dir: directory path of the folder that contains the theme - """ - self.name = name - self.theme_dir_name = theme_dir_name - self.themes_base_dir = themes_base_dir - - def __eq__(self, other): - """ - Returns True if given theme is same as the self - Args: - other: Theme object to compare with self - - Returns: - (bool) True if two themes are the same else False - """ - return (self.theme_dir_name, self.path) == (other.theme_dir_name, other.path) - - def __hash__(self): - return hash((self.theme_dir_name, self.path)) - - def __unicode__(self): - return u"".format(name=self.name, path=self.path) - - def __repr__(self): - return self.__unicode__() - - @property - def path(self): - """ - Get absolute path of the directory that contains current theme's templates, static assets etc. - - Returns: - Path: absolute path to current theme's contents - """ - return Path(self.themes_base_dir) / self.theme_dir_name / get_project_root_name() - - @property - def template_path(self): - """ - Get absolute path of current theme's template directory. - - Returns: - Path: absolute path to current theme's template directory - """ - return Path(self.theme_dir_name) / get_project_root_name() / 'templates' - - @property - def template_dirs(self): - """ - Get a list of all template directories for current theme. - - Returns: - list: list of all template directories for current theme. - """ - return [ - self.path / 'templates', - ] diff --git a/openedx/core/djangoapps/theming/helpers_dirs.py b/openedx/core/djangoapps/theming/helpers_dirs.py new file mode 100644 index 0000000000..7439aed5e9 --- /dev/null +++ b/openedx/core/djangoapps/theming/helpers_dirs.py @@ -0,0 +1,165 @@ +""" +Code which dynamically discovers comprehensive themes. Deliberately uses no Django settings, +as the discovery happens during the initial setup of Django settings. +""" +import os +from path import Path + + +def get_theme_base_dirs_from_settings(theme_dirs=None): + """ + Return base directories that contains all the themes. + + Example: + >> get_theme_base_dirs_from_settings('/edx/app/ecommerce/ecommerce/themes') + ['/edx/app/ecommerce/ecommerce/themes'] + + Returns: + (List of Paths): Base theme directory paths + """ + theme_base_dirs = [] + if theme_dirs: + theme_base_dirs.extend([Path(theme_dir) for theme_dir in theme_dirs]) + return theme_base_dirs + + +def get_themes_unchecked(themes_dirs, project_root=None): + """ + Returns a list of all themes known to the system. + + Args: + themes_dirs (list): Paths to themes base directory + project_root (str): (optional) Path to project root + Returns: + List of themes known to the system. + """ + themes_base_dirs = [Path(themes_dir) for themes_dir in themes_dirs] + # pick only directories and discard files in themes directory + themes = [] + for themes_dir in themes_base_dirs: + themes.extend([Theme(name, name, themes_dir, project_root) for name in get_theme_dirs(themes_dir)]) + + return themes + + +def get_theme_dirs(themes_dir=None): + """ + Returns theme dirs in given dirs + Args: + themes_dir (Path): base dir that contains themes. + """ + return [_dir for _dir in os.listdir(themes_dir) if is_theme_dir(themes_dir / _dir)] + + +def is_theme_dir(_dir): + """ + Returns true if given dir contains theme overrides. + A theme dir must have subdirectory 'lms' or 'cms' or both. + + Args: + _dir: directory path to check for a theme + + Returns: + Returns true if given dir is a theme directory. + """ + theme_sub_directories = {'lms', 'cms'} + return bool(os.path.isdir(_dir) and theme_sub_directories.intersection(os.listdir(_dir))) + + +def get_project_root_name_from_settings(project_root): + """ + Return root name for the current project + + Example: + >> get_project_root_name() + 'lms' + # from studio + >> get_project_root_name() + 'cms' + + Args: + project_root (str): Root directory of the project. + + Returns: + (str): component name of platform e.g lms, cms + """ + root = Path(project_root) + if root.name == "": + root = root.parent + return root.name + + +class Theme(object): + """ + class to encapsulate theme related information. + """ + name = '' + theme_dir_name = '' + themes_base_dir = None + project_root = None + + def __init__(self, name='', theme_dir_name='', themes_base_dir=None, project_root=None): + """ + init method for Theme + + Args: + name: name if the theme + theme_dir_name: directory name of the theme + themes_base_dir: directory path of the folder that contains the theme + """ + self.name = name + self.theme_dir_name = theme_dir_name + self.themes_base_dir = themes_base_dir + self.project_root = project_root + + def __eq__(self, other): + """ + Returns True if given theme is same as the self + Args: + other: Theme object to compare with self + + Returns: + (bool) True if two themes are the same else False + """ + return (self.theme_dir_name, self.path) == (other.theme_dir_name, other.path) + + def __hash__(self): + return hash((self.theme_dir_name, self.path)) + + def __unicode__(self): + return u"".format(name=self.name, path=self.path) + + def __repr__(self): + return self.__unicode__() + + @property + def path(self): + """ + Get absolute path of the directory that contains current theme's templates, static assets etc. + + Returns: + Path: absolute path to current theme's contents + """ + return Path(self.themes_base_dir) / self.theme_dir_name / get_project_root_name_from_settings(self.project_root) + + @property + def template_path(self): + """ + Get absolute path of current theme's template directory. + + Returns: + Path: absolute path to current theme's template directory + """ + return Path(self.theme_dir_name) / get_project_root_name_from_settings(self.project_root) / 'templates' + + @property + def template_dirs(self): + """ + Get a list of all template directories for current theme. + + Returns: + list: list of all template directories for current theme. + """ + return [ + self.path / 'templates', + ] diff --git a/openedx/core/djangoapps/theming/helpers_static.py b/openedx/core/djangoapps/theming/helpers_static.py new file mode 100644 index 0000000000..9fc54c9e03 --- /dev/null +++ b/openedx/core/djangoapps/theming/helpers_static.py @@ -0,0 +1,19 @@ + +from django.contrib.staticfiles.storage import staticfiles_storage + + +def get_static_file_url(asset): + """ + Returns url of the themed asset if asset is not themed than returns the default asset url. + + Example: + >> get_static_file_url('css/lms-main-v1.css') + '/static/red-theme/css/lms-main-v1.css' + + Parameters: + asset (str): asset's path relative to the static files directory + + Returns: + (str): static asset's url + """ + return staticfiles_storage.url(asset) diff --git a/openedx/core/djangoapps/theming/management/commands/compile_sass.py b/openedx/core/djangoapps/theming/management/commands/compile_sass.py index 9d3a33cab2..5b3f9640fa 100644 --- a/openedx/core/djangoapps/theming/management/commands/compile_sass.py +++ b/openedx/core/djangoapps/theming/management/commands/compile_sass.py @@ -92,7 +92,7 @@ class Command(BaseCommand): if theme_dirs: available_themes = {} for theme_dir in theme_dirs: - available_themes.update({t.theme_dir_name: t for t in get_themes(theme_dir)}) + available_themes.update({t.theme_dir_name: t for t in get_themes([theme_dir])}) else: theme_dirs = get_theme_base_dirs() available_themes = {t.theme_dir_name: t for t in get_themes()} diff --git a/openedx/core/djangoapps/theming/templatetags/theme_pipeline.py b/openedx/core/djangoapps/theming/templatetags/theme_pipeline.py index 7beb99ca55..3a79e7fd96 100644 --- a/openedx/core/djangoapps/theming/templatetags/theme_pipeline.py +++ b/openedx/core/djangoapps/theming/templatetags/theme_pipeline.py @@ -9,7 +9,7 @@ from django.utils.safestring import mark_safe from pipeline.templatetags.pipeline import StylesheetNode, JavascriptNode from pipeline.utils import guess_type -from openedx.core.djangoapps.theming.helpers import get_static_file_url +from openedx.core.djangoapps.theming.helpers_static import get_static_file_url register = template.Library() # pylint: disable=invalid-name diff --git a/openedx/core/djangoapps/theming/tests/test_helpers.py b/openedx/core/djangoapps/theming/tests/test_helpers.py index 862d5c95f3..96e441004c 100644 --- a/openedx/core/djangoapps/theming/tests/test_helpers.py +++ b/openedx/core/djangoapps/theming/tests/test_helpers.py @@ -22,13 +22,13 @@ class TestHelpers(TestCase): Tests template paths are returned from enabled theme. """ expected_themes = [ - Theme('dark-theme', 'dark-theme', get_theme_base_dir('dark-theme')), - Theme('edge.edx.org', 'edge.edx.org', get_theme_base_dir('edge.edx.org')), - Theme('edx.org', 'edx.org', get_theme_base_dir('edx.org')), - Theme('open-edx', 'open-edx', get_theme_base_dir('open-edx')), - Theme('red-theme', 'red-theme', get_theme_base_dir('red-theme')), - Theme('stanford-style', 'stanford-style', get_theme_base_dir('stanford-style')), - Theme('test-theme', 'test-theme', get_theme_base_dir('test-theme')), + Theme('dark-theme', 'dark-theme', get_theme_base_dir('dark-theme'), settings.PROJECT_ROOT), + Theme('edge.edx.org', 'edge.edx.org', get_theme_base_dir('edge.edx.org'), settings.PROJECT_ROOT), + Theme('edx.org', 'edx.org', get_theme_base_dir('edx.org'), settings.PROJECT_ROOT), + Theme('open-edx', 'open-edx', get_theme_base_dir('open-edx'), settings.PROJECT_ROOT), + Theme('red-theme', 'red-theme', get_theme_base_dir('red-theme'), settings.PROJECT_ROOT), + Theme('stanford-style', 'stanford-style', get_theme_base_dir('stanford-style'), settings.PROJECT_ROOT), + Theme('test-theme', 'test-theme', get_theme_base_dir('test-theme'), settings.PROJECT_ROOT), ] actual_themes = get_themes() self.assertItemsEqual(expected_themes, actual_themes) @@ -39,7 +39,7 @@ class TestHelpers(TestCase): Tests template paths are returned from enabled theme. """ expected_themes = [ - Theme('test-theme', 'test-theme', get_theme_base_dir('test-theme')), + Theme('test-theme', 'test-theme', get_theme_base_dir('test-theme'), settings.PROJECT_ROOT), ] actual_themes = get_themes() self.assertItemsEqual(expected_themes, actual_themes) From 4608aee3b38ca35582a548df1bd606bded46633f Mon Sep 17 00:00:00 2001 From: Harry Rein Date: Mon, 30 Oct 2017 16:31:56 -0400 Subject: [PATCH 08/47] Allow scrolling up on LMS. --- lms/static/js/header/header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/static/js/header/header.js b/lms/static/js/header/header.js index 6803a6ca33..3c9dc7144c 100644 --- a/lms/static/js/header/header.js +++ b/lms/static/js/header/header.js @@ -106,7 +106,7 @@ $(document).on('keydown', function(e) { } // Enable arrow functionality within the menu. - if (e.keyCode === 38 || e.keyCode === 40 && (isDropdownOption || isMobileOption || + if ((e.keyCode === 38 || e.keyCode === 40) && (isDropdownOption || isMobileOption || (isHamburgerMenu && $hamburgerMenu.hasClass('open')) || isToggle && $toggleUserDropdown.hasClass('open'))) { isNext = e.keyCode === 40; if (isNext && !isHamburgerMenu && !isToggle && isLastItem) { From 97c1a7580b1a823757f788b18004841e31678817 Mon Sep 17 00:00:00 2001 From: Gregory Martin Date: Mon, 30 Oct 2017 16:37:39 -0400 Subject: [PATCH 09/47] Fix onload tabindex focus for wiki edits --- lms/templates/wiki/includes/editor.html | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/templates/wiki/includes/editor.html b/lms/templates/wiki/includes/editor.html index 7325310852..caf8e16d3f 100644 --- a/lms/templates/wiki/includes/editor.html +++ b/lms/templates/wiki/includes/editor.html @@ -6,6 +6,7 @@ From 3b865c92c516d60e3ab674405914409f4ec60a11 Mon Sep 17 00:00:00 2001 From: Douglas Hall Date: Mon, 30 Oct 2017 20:32:20 -0400 Subject: [PATCH 10/47] Upgrade edx-enterprise to 0.53.7. https://github.com/edx/edx-enterprise/compare/0.53.6...0.53.7 --- requirements/edx/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index be47833b48..5303c35896 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -47,7 +47,7 @@ edx-lint==0.4.3 astroid==1.3.8 edx-django-oauth2-provider==1.2.5 edx-django-sites-extensions==2.3.0 -edx-enterprise==0.53.6 +edx-enterprise==0.53.7 edx-oauth2-provider==1.2.2 edx-opaque-keys==0.4.0 edx-organizations==0.4.7 From 43150a1d23b17d5dc9aa890b6044a6f37bbd76f1 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 31 Oct 2017 12:55:28 -0400 Subject: [PATCH 11/47] GMT -> UTC in test --- common/test/acceptance/tests/lms/test_lms_dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/test/acceptance/tests/lms/test_lms_dashboard.py b/common/test/acceptance/tests/lms/test_lms_dashboard.py index 7ea1b3f348..51a0f132ff 100644 --- a/common/test/acceptance/tests/lms/test_lms_dashboard.py +++ b/common/test/acceptance/tests/lms/test_lms_dashboard.py @@ -302,7 +302,7 @@ class LmsDashboardPageTest(BaseLmsDashboardTest): self.course_fixture.configure_course() start_date = TEST_DATE_FORMAT.format(dt=course_start_date) - expected_course_date = "Starts - {start_date} GMT".format(start_date=start_date) + expected_course_date = "Starts - {start_date} UTC".format(start_date=start_date) # reload the page for changes to course date changes to appear in dashboard self.dashboard_page.visit() From 1e599bed09a549a35798c0870f71b69fe29a879e Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 31 Oct 2017 12:51:59 -0400 Subject: [PATCH 12/47] Add lms_initialization app This app is a grab bag of init code which can't find a good home in other apps. This was created in response to removing lms.startup.run(). --- lms/djangoapps/lms_initialization/__init__.py | 3 +++ lms/djangoapps/lms_initialization/apps.py | 25 +++++++++++++++++++ lms/envs/common.py | 3 +++ 3 files changed, 31 insertions(+) create mode 100644 lms/djangoapps/lms_initialization/__init__.py create mode 100644 lms/djangoapps/lms_initialization/apps.py diff --git a/lms/djangoapps/lms_initialization/__init__.py b/lms/djangoapps/lms_initialization/__init__.py new file mode 100644 index 0000000000..a89aa1b1b1 --- /dev/null +++ b/lms/djangoapps/lms_initialization/__init__.py @@ -0,0 +1,3 @@ +""" +Initialization app for the LMS +""" diff --git a/lms/djangoapps/lms_initialization/apps.py b/lms/djangoapps/lms_initialization/apps.py new file mode 100644 index 0000000000..330d0e3fa2 --- /dev/null +++ b/lms/djangoapps/lms_initialization/apps.py @@ -0,0 +1,25 @@ +""" +Initialization app for the LMS + +This app consists solely of a ready method in its AppConfig, and should be +included early in the INSTALLED_APPS list. +""" + +import analytics +from django.apps import AppConfig +from django.conf import settings + + +class LMSInitializationConfig(AppConfig): + """ + Application Configuration for lms_initialization. + """ + name = 'lms_initialization' + verbose_name = 'LMS Initialization' + + def ready(self): + """ + Global LMS initialization methods are called here. This runs after + settings have loaded, but before most other djangoapp initializations. + """ + pass diff --git a/lms/envs/common.py b/lms/envs/common.py index bbbf537e19..43230c3216 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1988,6 +1988,9 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'djcelery', + # Initialization + 'lms_initialization.apps.LMSInitializationConfig', + # Common views 'openedx.core.djangoapps.common_views', From 20b6dd51828a7bf3d37c9063c55582554cc8bbcf Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 31 Oct 2017 12:54:42 -0400 Subject: [PATCH 13/47] Move analytics initialization out of lms.startup.run lms.startup is being removed, so initialize the analytics module in the lms_initialization ready() method instead. --- lms/djangoapps/lms_initialization/apps.py | 9 ++++++++- lms/startup.py | 5 ----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/lms_initialization/apps.py b/lms/djangoapps/lms_initialization/apps.py index 330d0e3fa2..12002ca0b0 100644 --- a/lms/djangoapps/lms_initialization/apps.py +++ b/lms/djangoapps/lms_initialization/apps.py @@ -22,4 +22,11 @@ class LMSInitializationConfig(AppConfig): Global LMS initialization methods are called here. This runs after settings have loaded, but before most other djangoapp initializations. """ - pass + self._initialize_analytics() + + def _initialize_analytics(self): + """ + Initialize Segment analytics module by setting the write_key. + """ + if settings.LMS_SEGMENT_KEY: + analytics.write_key = settings.LMS_SEGMENT_KEY diff --git a/lms/startup.py b/lms/startup.py index 05b4ff18f9..33e2ff3b18 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -13,7 +13,6 @@ settings.INSTALLED_APPS # pylint: disable=pointless-statement from openedx.core.lib.django_startup import autostartup from openedx.core.release import doc_version -import analytics from openedx.core.djangoapps.monkey_patch import django_db_models_options @@ -57,10 +56,6 @@ def run(): # Mako requires the directories to be added after the django setup. microsite.enable_microsites(log) - # Initialize Segment analytics module by setting the write_key. - if settings.LMS_SEGMENT_KEY: - analytics.write_key = settings.LMS_SEGMENT_KEY - # register any dependency injections that we need to support in edx_proctoring # right now edx_proctoring is dependent on the openedx.core.djangoapps.credit and # lms.djangoapps.grades From e2060b60c3dc65d09fdf56cb9fd9e2af05352c51 Mon Sep 17 00:00:00 2001 From: John Eskew Date: Tue, 31 Oct 2017 09:56:24 -0400 Subject: [PATCH 14/47] Move signal registering to AppConfig's ready from startup.py --- common/djangoapps/course_modes/apps.py | 10 ++++++++++ common/djangoapps/course_modes/startup.py | 4 ---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 common/djangoapps/course_modes/apps.py delete mode 100644 common/djangoapps/course_modes/startup.py diff --git a/common/djangoapps/course_modes/apps.py b/common/djangoapps/course_modes/apps.py new file mode 100644 index 0000000000..d0b1b2c32c --- /dev/null +++ b/common/djangoapps/course_modes/apps.py @@ -0,0 +1,10 @@ + +from django.apps import AppConfig + + +class CourseModesConfig(AppConfig): + name = 'course_modes' + verbose_name = "Course Modes" + + def ready(self): + import course_modes.signals # pylint: disable=unused-import diff --git a/common/djangoapps/course_modes/startup.py b/common/djangoapps/course_modes/startup.py deleted file mode 100644 index c2e0f4d49d..0000000000 --- a/common/djangoapps/course_modes/startup.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Setup the signals on startup. -""" -import course_modes.signals # pylint: disable=unused-import From fcfb40cf52e68ddb9556e36f638e715d299ca8c7 Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Tue, 31 Oct 2017 14:42:55 -0400 Subject: [PATCH 15/47] Remove pragmas to disable mako-missing-default --- cms/templates/base.html | 3 +-- cms/templates/ux/reference/fragments/course-settings.html | 2 +- lms/templates/main.html | 2 -- lms/templates/ux/reference/fragments/unit-fragment.html | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cms/templates/base.html b/cms/templates/base.html index cbe419c9e4..d0e4c212e7 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -1,6 +1,5 @@ -## xss-lint: disable=mako-missing-default - ## coding=utf-8 +## mako ## Pages currently use v1 styling by default. Once the Pattern Library ## rollout has been completed, this default can be switched to v2. diff --git a/cms/templates/ux/reference/fragments/course-settings.html b/cms/templates/ux/reference/fragments/course-settings.html index 593ec77ced..c6549ac5b4 100644 --- a/cms/templates/ux/reference/fragments/course-settings.html +++ b/cms/templates/ux/reference/fragments/course-settings.html @@ -1,5 +1,5 @@ +<%page expression_filter="h"/>
diff --git a/lms/templates/main.html b/lms/templates/main.html index 5599ff613e..52576191e3 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -1,5 +1,3 @@ -## xss-lint: disable=mako-missing-default - ## coding=utf-8 ## This is the main Mako template that all page templates should include. diff --git a/lms/templates/ux/reference/fragments/unit-fragment.html b/lms/templates/ux/reference/fragments/unit-fragment.html index 9b5847a043..1a0108aa1a 100644 --- a/lms/templates/ux/reference/fragments/unit-fragment.html +++ b/lms/templates/ux/reference/fragments/unit-fragment.html @@ -1,6 +1,6 @@ ## mako +<%page expression_filter="h"/>
From f9f4876bee81e1f0c1c2face109ff838ca5fa914 Mon Sep 17 00:00:00 2001 From: Bill Filler Date: Tue, 5 Sep 2017 10:47:56 -0400 Subject: [PATCH 16/47] Add 'View Consent' button to dashboard when required Enterprise customers can require user to agree to Data Sharing Consent form before they can access a course. We now add it conditionally to Course Dashboard when it's required so it's apparent to user and they have a way to revist the consent form if they've previously declined or the course has not yet started. WL-1281 --- common/djangoapps/student/tests/test_views.py | 41 ++++ common/djangoapps/student/views.py | 18 +- lms/static/sass/multicourse/_dashboard.scss | 11 +- lms/templates/dashboard.html | 3 +- .../dashboard/_dashboard_course_listing.html | 196 +++++++++--------- .../dashboard/_dashboard_show_consent.html | 25 +++ 6 files changed, 194 insertions(+), 100 deletions(-) create mode 100644 lms/templates/dashboard/_dashboard_show_consent.html diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index cf20f209f9..790cafd809 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -7,6 +7,7 @@ import json import unittest import ddt +import mock import pytz from django.conf import settings from django.core.urlresolvers import reverse @@ -335,3 +336,43 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin): remove_prerequisite_course(self.course.id, get_course_milestones(self.course.id)[0]) response = self.client.get(reverse('dashboard')) self.assertNotIn('
', response.content) + + @mock.patch('student.views.consent_needed_for_course') + @mock.patch('student.views.enterprise_customer_for_request') + @ddt.data( + (True, True, True), + (True, True, False), + (True, False, False), + (False, True, False), + (False, False, False), + ) + @ddt.unpack + def test_enterprise_view_consent_for_course( + self, + enterprise_enabled, + consent_needed, + future_course, + mock_enterprise_customer, + mock_consent_necessary + ): + """ + Verify that the 'View Consent' icon show up if data sharing consent turned on + for enterprise customer + """ + if future_course: + self.course = CourseFactory.create(start=self.TOMORROW, emit_signals=True) + else: + self.course = CourseFactory.create(emit_signals=True) + self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) + + if enterprise_enabled: + mock_enterprise_customer.return_value = {'name': 'TestEnterprise', 'uuid': 'abc123xxx'} + else: + mock_enterprise_customer.return_value = None + + mock_consent_necessary.return_value = consent_needed + + # Assert 'View Consent' button shows up appropriately + response = self.client.get(reverse('dashboard')) + self.assertEquals('View Consent' in response.content, enterprise_enabled and consent_needed) + self.assertEquals('TestEnterprise' in response.content, enterprise_enabled and consent_needed) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index c3c3cd0151..220cf31d40 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -87,7 +87,11 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangolib.markup import HTML from openedx.features.course_experience import course_home_url_name -from openedx.features.enterprise_support.api import get_dashboard_consent_notification +from openedx.features.enterprise_support.api import ( + consent_needed_for_course, + enterprise_customer_for_request, + get_dashboard_consent_notification +) from shoppingcart.api import order_history from shoppingcart.models import CourseRegistrationCode, DonationConfiguration from student.cookies import delete_logged_in_cookies, set_logged_in_cookies, set_user_info_cookie @@ -729,6 +733,16 @@ def dashboard(request): enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) + enterprise_customer = enterprise_customer_for_request(request) + consent_required_courses = set() + enterprise_customer_name = None + if enterprise_customer: + consent_required_courses = { + enrollment.course_id for enrollment in course_enrollments + if consent_needed_for_course(request, request.user, str(enrollment.course_id), True) + } + enterprise_customer_name = enterprise_customer['name'] + # Account activation message account_activation_messages = [ message for message in messages.get_messages(request) if 'account-activation' in message.tags @@ -847,6 +861,8 @@ def dashboard(request): context = { 'enterprise_message': enterprise_message, + 'consent_required_courses': consent_required_courses, + 'enterprise_customer_name': enterprise_customer_name, 'enrollment_message': enrollment_message, 'redirect_message': redirect_message, 'account_activation_messages': account_activation_messages, diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index c0f9974564..a4a61d3b65 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -721,7 +721,7 @@ @include clearfix(); - position: relative; + position: inherit; @include left($baseline/2); @include padding(($baseline * 0.4), 0, ($baseline * 0.4), ($baseline * 0.75)); @@ -772,6 +772,15 @@ opacity: 0.875; } } + + .action-view-consent { + @extend %btn-pl-white-base; + @include float(right); + + &.archived { + @extend %btn-pl-default-base; + } + } } // TYPE: status diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 9c9a27ea8c..b70271f6bf 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -128,7 +128,8 @@ from openedx.core.djangolib.markup import HTML, Text <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> <% related_programs = inverted_programs.get(unicode(enrollment.course_id)) %> - <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard' /> + <% show_consent_link = (enrollment.course_id in consent_required_courses) %> + <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> % endfor diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 0183816b71..e8030ea9ca 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,4 +1,4 @@ -<%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard" expression_filter="h"/> +<%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name" expression_filter="h"/> <%! import urllib @@ -289,110 +289,112 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ <%include file="_dashboard_credit_info.html" args="credit_status=credit_status"/> % endif - % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY] and not is_course_blocked: -
- % if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY: -
- % if verification_status['days_until_deadline'] is not None: -

${_('Verification not yet complete.')}

-

${ungettext( - 'You only have {days} day left to verify for this course.', - 'You only have {days} days left to verify for this course.', - verification_status['days_until_deadline'] - ).format(days=verification_status['days_until_deadline'])}

- % else: -

${_('Almost there!')}

-

${_('You still need to verify for this course.')}

+ % if is_course_blocked: +

+ ${Text(_("You can no longer access this course because payment has not yet been received. " + "You can {contact_link_start}contact the account holder{contact_link_end} " + "to request payment, or you can " + "{unenroll_link_start}unenroll{unenroll_link_end} " + "from this course")).format( + contact_link_start=HTML(''), + unenroll_link_start=HTML( + '' + ).format( + course_id=course_overview.id, + course_number=course_overview.number, + course_name=course_overview.display_name_with_default, + ), + unenroll_link_end=HTML(''), + )} +

+ % else: + % if show_consent_link: + <%include file="_dashboard_show_consent.html" args="course_overview=course_overview, course_target=course_target, enrollment=enrollment, enterprise_customer_name=enterprise_customer_name"/> + %endif + + % if verification_status.get('status') in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_RESUBMITTED, VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_REVERIFY]: +
+ % if verification_status['status'] == VERIFY_STATUS_NEED_TO_VERIFY: +
+ % if verification_status['days_until_deadline'] is not None: +

${_('Verification not yet complete.')}

+

${ungettext( + 'You only have {days} day left to verify for this course.', + 'You only have {days} days left to verify for this course.', + verification_status['days_until_deadline'] + ).format(days=verification_status['days_until_deadline'])}

+ % else: +

${_('Almost there!')}

+

${_('You still need to verify for this course.')}

+ % endif +
+ + % elif verification_status['status'] == VERIFY_STATUS_SUBMITTED: +

${_('You have submitted your verification information.')}

+

${_('You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).')}

+ % elif verification_status['status'] == VERIFY_STATUS_RESUBMITTED: +

${_('Your current verification will expire soon!')}

+

${_('You have submitted your reverification information. You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).')}

+ % elif verification_status['status'] == VERIFY_STATUS_APPROVED: +

${_('You have successfully verified your ID with edX')}

+ % if verification_status.get('verification_good_until') is not None: +

${_('Your current verification is effective until {date}.').format(date=verification_status['verification_good_until'])} % endif -

- - % elif verification_status['status'] == VERIFY_STATUS_SUBMITTED: -

${_('You have submitted your verification information.')}

-

${_('You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).')}

- % elif verification_status['status'] == VERIFY_STATUS_RESUBMITTED: -

${_('Your current verification will expire soon!')}

-

${_('You have submitted your reverification information. You will see a message on your dashboard when the verification process is complete (usually within 1-2 days).')}

- % elif verification_status['status'] == VERIFY_STATUS_APPROVED: -

${_('You have successfully verified your ID with edX')}

- % if verification_status.get('verification_good_until') is not None: -

${_('Your current verification is effective until {date}.').format(date=verification_status['verification_good_until'])} + % elif verification_status['status'] == VERIFY_STATUS_NEED_TO_REVERIFY: +

${_('Your current verification will expire soon.')}

+ ## Translators: start_link and end_link will be replaced with HTML tags; + ## please do not translate these. +

${Text(_('Your current verification will expire in {days} days. {start_link}Re-verify your identity now{end_link} using a webcam and a government-issued photo ID.')).format( + start_link=HTML('').format(href=reverse('verify_student_reverify')), + end_link=HTML(''), + days=settings.VERIFY_STUDENT.get("EXPIRING_SOON_WINDOW") + )} +

% endif - % elif verification_status['status'] == VERIFY_STATUS_NEED_TO_REVERIFY: -

${_('Your current verification will expire soon.')}

- ## Translators: start_link and end_link will be replaced with HTML tags; - ## please do not translate these. -

${Text(_('Your current verification will expire in {days} days. {start_link}Re-verify your identity now{end_link} using a webcam and a government-issued photo ID.')).format( - start_link=HTML('').format(href=reverse('verify_student_reverify')), - end_link=HTML(''), - days=settings.VERIFY_STUDENT.get("EXPIRING_SOON_WINDOW") - )} -

- % endif -
+
% endif - % if course_mode_info['show_upsell'] and not is_course_blocked: + % if course_mode_info['show_upsell']:
-
-

- - ${_("Pursue a {cert_name_long} to highlight the knowledge and skills you gain in this course.").format(cert_name_long=cert_name_long)} -
- ${Text(_("It's official. It's easily shareable. " - "It's a proven motivator to complete the course. {line_break}" - "{link_start}Learn more about the verified {cert_name_long}{link_end}.")).format( - line_break=HTML('
'), - link_start=HTML('').format( - marketing_link('WHAT_IS_VERIFIED_CERT'), - enrollment.course_id - ), - link_end=HTML(''), - cert_name_long=cert_name_long - )} -

- +

+ + ${_("Pursue a {cert_name_long} to highlight the knowledge and skills you gain in this course.").format(cert_name_long=cert_name_long)} +
+ ${Text(_("It's official. It's easily shareable. " + "It's a proven motivator to complete the course. {line_break}" + "{link_start}Learn more about the verified {cert_name_long}{link_end}.")).format( + line_break=HTML('
'), + link_start=HTML('').format( + marketing_link('WHAT_IS_VERIFIED_CERT'), + enrollment.course_id + ), + link_end=HTML(''), + cert_name_long=cert_name_long + )} +

+
- %endif - - % if is_course_blocked: -

- ${Text(_("You can no longer access this course because payment has not yet been received. " - "You can {contact_link_start}contact the account holder{contact_link_end} " - "to request payment, or you can " - "{unenroll_link_start}unenroll{unenroll_link_end} " - "from this course")).format( - contact_link_start=HTML(''), - unenroll_link_start=HTML( - '' - ).format( - course_id=course_overview.id, - course_number=course_overview.number, - course_name=course_overview.display_name_with_default, - ), - unenroll_link_end=HTML(''), - )} -

- %endif - + % endif + % endif % if course_requirements: ## Multiple pre-requisite courses are not supported on frontend that's why we are pulling first element diff --git a/lms/templates/dashboard/_dashboard_show_consent.html b/lms/templates/dashboard/_dashboard_show_consent.html new file mode 100644 index 0000000000..0217cb44ba --- /dev/null +++ b/lms/templates/dashboard/_dashboard_show_consent.html @@ -0,0 +1,25 @@ +<%page expression_filter="h" args="course_overview, course_target, enrollment, enterprise_customer_name" /> +<%! +from django.utils.translation import ugettext as _ +%> +<%namespace name='static' file='../static_content.html'/> + +
+
+

+ + ${_("Consent to share your data")} + +
+ ${_("To access this course, you must first consent to share your learning achievements with {enterprise_customer_name}.").format(enterprise_customer_name=enterprise_customer_name)} +

+ +
+
\ No newline at end of file From 30dfa98bf08fbf38027efc70f2075cc0ce15d74e Mon Sep 17 00:00:00 2001 From: Nimisha Asthagiri Date: Mon, 30 Oct 2017 17:11:42 -0400 Subject: [PATCH 17/47] Section Highlights Studio UI: Encircle number of highlights --- .../js/spec/views/pages/course_outline_spec.js | 12 +++++++----- cms/static/sass/views/_outline.scss | 18 ++++++++++++++++-- cms/templates/js/course-outline.underscore | 15 ++++----------- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index f92b986bb6..b2f0387fd6 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -532,7 +532,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j describe('Section Highlights', function() { var createCourse, createCourseWithHighlights, createCourseWithHighlightsDisabled, mockHighlightValues, highlightsLink, highlightInputs, openHighlights, saveHighlights, setHighlights, - expectHighlightLinkTextToBe, expectHighlightsToBe, expectServerHandshakeWithHighlights, + expectHighlightLinkNumberToBe, expectHighlightsToBe, expectServerHandshakeWithHighlights, expectHighlightsToUpdate, maxNumHighlights = 5; @@ -591,8 +591,10 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j } }; - expectHighlightLinkTextToBe = function(expectedValue) { - expect(highlightsLink()).toContainText(expectedValue); + expectHighlightLinkNumberToBe = function(expectedNumber) { + var link = highlightsLink(); + expect(link).toContainText('Section Highlights'); + expect(link.find('.number-highlights')).toHaveHtml(expectedNumber); }; expectHighlightsToBe = function(expectedHighlights) { @@ -645,13 +647,13 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/j it('displays link when no highlights exist', function() { createCourseWithHighlights([]); - expectHighlightLinkTextToBe('Enter Section Highlights'); + expectHighlightLinkNumberToBe(0); }); it('displays link when highlights exist', function() { var highlights = mockHighlightValues(2); createCourseWithHighlights(highlights); - expectHighlightLinkTextToBe('Section Highlights: 2 entered'); + expectHighlightLinkNumberToBe(2); }); it('can view when no highlights exist', function() { diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index f2702cbfcd..ab5880b39b 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -640,10 +640,24 @@ color: theme-color("primary"); } + .number-highlights { + background: theme-color("primary"); + border-radius: 50%; + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + color: $white; + display: inline-block; + font-weight: bold; + line-height: 18px; + margin-right: 2px; + text-align: center; + width: 18px; + } + .highlight-input-text { width: 100%; - margin-bottom: 5px; - margin-top: 5px; + margin-bottom: ($baseline/4); + margin-top: ($baseline/4); } .highlights-description { diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 30a3506920..22dbb071c1 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -201,20 +201,13 @@ if (is_proctored_exam) {
<% } %> <% if (xblockInfo.get('highlights_enabled') && course.get('self_paced') && xblockInfo.isChapter()) { %> -
- <%- gettext('Highlights:') %> +
<% var number_of_highlights = (xblockInfo.get('highlights') || []).length; %> - <% if (number_of_highlights > 0) { %> - <%- edx.StringUtils.interpolate( - gettext('Section Highlights: {number_of_highlights} entered'), - {number_of_highlights: number_of_highlights} - ) %> + <%- number_of_highlights %> + <%- gettext('Section Highlights') %> - <% } else { %> - <%- gettext('Enter Section Highlights') %> - <% } %> -
+
<% } %> <% if (xblockInfo.get('is_time_limited')) { %>
From 27258425d2a8b9c286cb880e399cfa7aafa5beac Mon Sep 17 00:00:00 2001 From: Eric Fischer Date: Thu, 26 Oct 2017 14:27:40 -0400 Subject: [PATCH 18/47] Send contextual data through to studio-frontend EDUCATOR-1529 --- cms/templates/asset_index.html | 13 ++++++- .../templates/static_content.html | 39 +++++++++++++++++++ scripts/tests/test_xss_linter.py | 8 +++- scripts/xss_linter.py | 2 + 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 65d75daaa9..eaf351e519 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -56,8 +56,17 @@
% if waffle_flag_enabled: -
- <%static:webpack entry="AssetsPage"> + <%static:studiofrontend page="AssetsPage" lang="fr"> + { + "id": "${context_course.id | n, js_escaped_string}", + "name": "${context_course.display_name_with_default | n, js_escaped_string}", + "url_name": "${context_course.location.name | n, js_escaped_string}", + "org": "${context_course.location.org | n, js_escaped_string}", + "num": "${context_course.location.course | n, js_escaped_string}", + "display_course_number": "${context_course.display_coursenumber | n, js_escaped_string}", + "revision": "${context_course.location.revision | n, js_escaped_string}" + } + % else:
% endif diff --git a/common/djangoapps/pipeline_mako/templates/static_content.html b/common/djangoapps/pipeline_mako/templates/static_content.html index 6e0aecc977..9767468c2a 100644 --- a/common/djangoapps/pipeline_mako/templates/static_content.html +++ b/common/djangoapps/pipeline_mako/templates/static_content.html @@ -86,6 +86,45 @@ engine = Engine(dirs=settings.DEFAULT_TEMPLATE_ENGINE['DIRS']) source, template_path = Loader(engine).load_template_source(path) %>${source | n, decode.utf8} +<%def name="studiofrontend(page, lang='en')"> + <%doc> + Loads a studio-frontend page, with the necessary context. Context is expected + as a dictionary in the body of this tag. + + Dev note: we could also add the locale-injection script in this block + -use a better default than the hardcoded 'en'. There should be a setting or something? + -lookup (webpack exported) locale-injection script using lang as key + -include it as the first script in this block + + <% + from django.template import Template, Context + from webpack_loader.exceptions import WebpackLoaderBadStatsError + import json + + def _convert_dict_to_json(input_dict): + output_json = "{" + for key in input_dict: + output_json = "{}{}:\"{}\",".format(output_json, key, input_dict[key]) + output_json += "}" + return output_json + + body = capture(caller.body) + body_dict = json.loads(body) + body_dict['lang'] = lang + return Template(""" + +
+ {% load render_bundle from webpack_loader %} + {% render_bundle page %} + """).render(Context({ + 'body': _convert_dict_to_json(body_dict), + 'page': page + })) + %> + + <%def name="webpack(entry)"> <%doc> Loads Javascript onto your page from a Webpack-generated bundle. diff --git a/scripts/tests/test_xss_linter.py b/scripts/tests/test_xss_linter.py index 2bfdd5e65b..b5239ba8b8 100644 --- a/scripts/tests/test_xss_linter.py +++ b/scripts/tests/test_xss_linter.py @@ -741,16 +741,22 @@ class TestMakoTemplateLinter(TestLinter): ${x | h} ${x | h} + <%static:studiofrontend page="${x}" lang="en"> + ${x | h} + + ${x | h} """) linter._check_mako_file_is_safe(mako_template, results) - self.assertEqual(len(results.violations), 5) + self.assertEqual(len(results.violations), 7) self.assertEqual(results.violations[0].rule, Rules.mako_unwanted_html_filter) self.assertEqual(results.violations[1].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[2].rule, Rules.mako_unwanted_html_filter) self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[4].rule, Rules.mako_unwanted_html_filter) + self.assertEqual(results.violations[5].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.violations[6].rule, Rules.mako_unwanted_html_filter) def test_check_mako_expressions_javascript_strings(self): """ diff --git a/scripts/xss_linter.py b/scripts/xss_linter.py index 42d071d971..aa89801332 100755 --- a/scripts/xss_linter.py +++ b/scripts/xss_linter.py @@ -2382,6 +2382,8 @@ class MakoTemplateLinter(BaseLinter): | # require js script tag end (optionally the _async version) <%static:webpack.*?> | # webpack script tag start | # webpack script tag end + <%static:studiofrontend.*?> | # studiofrontend script tag start + | # studiofrontend script tag end <%block[ ]*name=['"]requirejs['"]\w*> | # require js tag start # require js tag end """, From d549194ba3ec6a86c72ddaedb1cd6b63d8a9e007 Mon Sep 17 00:00:00 2001 From: Jeremy Bowman Date: Tue, 31 Oct 2017 15:50:14 -0400 Subject: [PATCH 19/47] PLAT-1775 Move help tokens configuration from startup to settings --- cms/envs/common.py | 4 ++++ cms/startup.py | 5 ----- lms/envs/common.py | 4 ++++ lms/startup.py | 5 ----- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index a0d36d2f8f..ad59310c02 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -133,6 +133,7 @@ from openedx.core.djangoapps.theming.helpers_dirs import ( ) from openedx.core.lib.license import LicenseMixin from openedx.core.lib.derived import derived, derived_dict_entry +from openedx.core.release import doc_version ############################ FEATURE CONFIGURATION ############################# @@ -1382,6 +1383,9 @@ AFFILIATE_COOKIE_NAME = 'affiliate_id' ############## Settings for Studio Context Sensitive Help ############## HELP_TOKENS_INI_FILE = REPO_ROOT / "cms" / "envs" / "help_tokens.ini" +HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE +HELP_TOKENS_VERSION = lambda settings: doc_version() +derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION') # This is required for the migrations in oauth_dispatch.models # otherwise it fails saying this attribute is not present in Settings diff --git a/cms/startup.py b/cms/startup.py index 4ed7cf4490..f49a2aed65 100644 --- a/cms/startup.py +++ b/cms/startup.py @@ -15,7 +15,6 @@ from openedx.core.lib.django_startup import autostartup settings.INSTALLED_APPS # pylint: disable=pointless-statement from openedx.core.lib.xblock_utils import xblock_local_resource_url -from openedx.core.release import doc_version from startup_configurations.validate_config import validate_cms_config @@ -41,10 +40,6 @@ def run(): xmodule.x_module.descriptor_global_handler_url = cms.lib.xblock.runtime.handler_url xmodule.x_module.descriptor_global_local_resource_url = xblock_local_resource_url - # Set the version of docs that help-tokens will go to. - settings.HELP_TOKENS_LANGUAGE_CODE = settings.LANGUAGE_CODE - settings.HELP_TOKENS_VERSION = doc_version() - # validate configurations on startup validate_cms_config(settings) diff --git a/lms/envs/common.py b/lms/envs/common.py index 61787e1077..74aecb184c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -44,6 +44,7 @@ from openedx.core.djangoapps.theming.helpers_dirs import ( get_theme_base_dirs_from_settings ) from openedx.core.lib.derived import derived, derived_dict_entry +from openedx.core.release import doc_version from xmodule.modulestore.modulestore_settings import update_module_store_settings from xmodule.modulestore.edit_info import EditInfoMixin from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin @@ -3295,10 +3296,13 @@ REDIRECT_CACHE_KEY_PREFIX = 'redirects' ############## Settings for LMS Context Sensitive Help ############## HELP_TOKENS_INI_FILE = REPO_ROOT / "lms" / "envs" / "help_tokens.ini" +HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE +HELP_TOKENS_VERSION = lambda settings: doc_version() HELP_TOKENS_BOOKS = { 'learner': 'http://edx.readthedocs.io/projects/open-edx-learner-guide', 'course_author': 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course', } +derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION') ############## OPEN EDX ENTERPRISE SERVICE CONFIGURATION ###################### # The Open edX Enterprise service is currently hosted via the LMS container/process. diff --git a/lms/startup.py b/lms/startup.py index 969093f34a..9366269a9b 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -12,7 +12,6 @@ from django.conf import settings settings.INSTALLED_APPS # pylint: disable=pointless-statement from openedx.core.lib.django_startup import autostartup -from openedx.core.release import doc_version from openedx.core.djangoapps.monkey_patch import django_db_models_options @@ -73,10 +72,6 @@ def run(): xmodule.x_module.descriptor_global_handler_url = lms_xblock.runtime.handler_url xmodule.x_module.descriptor_global_local_resource_url = lms_xblock.runtime.local_resource_url - # Set the version of docs that help-tokens will go to. - settings.HELP_TOKENS_LANGUAGE_CODE = settings.LANGUAGE_CODE - settings.HELP_TOKENS_VERSION = doc_version() - # validate configurations on startup validate_lms_config(settings) From ab011314eff48b6a308f57dcbb3989d3eaffd53a Mon Sep 17 00:00:00 2001 From: bradmerlin Date: Mon, 28 Aug 2017 17:23:17 +0200 Subject: [PATCH 20/47] Add student_view_data to HTML XBlock to allow the HTML to be downloadable via the Course Blocks API. Feature flag ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA must be set to enable this feature. --- common/lib/xmodule/xmodule/html_module.py | 17 +++++- .../xmodule/xmodule/tests/test_html_module.py | 59 ++++++++++++++++++- .../course_api/blocks/tests/test_views.py | 2 +- .../transformers/tests/test_student_view.py | 2 +- lms/envs/common.py | 3 + 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 02063444c0..5f569e7fe1 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -6,6 +6,7 @@ import sys import textwrap from datetime import datetime +from django.conf import settings from fs.errors import ResourceNotFoundError from lxml import etree from path import Path as path @@ -69,6 +70,8 @@ class HtmlBlock(object): scope=Scope.settings ) + ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA = 'ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA' + @XBlock.supports("multi_device") def student_view(self, _context): """ @@ -76,13 +79,25 @@ class HtmlBlock(object): """ return Fragment(self.get_html()) + def student_view_data(self, context=None): # pylint: disable=unused-argument + """ + Return a JSON representation of the student_view of this XBlock. + """ + if getattr(settings, 'FEATURES', {}).get(self.ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA, False): + return {'enabled': True, 'html': self.get_html()} + else: + return { + 'enabled': False, + 'message': 'To enable, set FEATURES["{}"]'.format(self.ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA) + } + def get_html(self): """ Returns html required for rendering XModule. """ # When we switch this to an XBlock, we can merge this with student_view, # but for now the XModule mixin requires that this method be defined. # pylint: disable=no-member - if self.system.anonymous_student_id: + if self.data is not None and getattr(self.system, 'anonymous_student_id', None) is not None: return self.data.replace("%%USER_ID%%", self.system.anonymous_student_id) return self.data diff --git a/common/lib/xmodule/xmodule/tests/test_html_module.py b/common/lib/xmodule/xmodule/tests/test_html_module.py index ec2413907c..318d9df440 100644 --- a/common/lib/xmodule/xmodule/tests/test_html_module.py +++ b/common/lib/xmodule/xmodule/tests/test_html_module.py @@ -1,6 +1,9 @@ import unittest - from mock import Mock +import ddt + +from django.test.utils import override_settings + from opaque_keys.edx.locator import CourseLocator from xblock.field_data import DictFieldData from xblock.fields import ScopeIds @@ -24,6 +27,60 @@ def instantiate_descriptor(**field_data): ) +@ddt.ddt +class HtmlModuleCourseApiTestCase(unittest.TestCase): + """ + Test the HTML XModule's student_view_data method. + """ + + @ddt.data( + dict(), + dict(FEATURES={}), + dict(FEATURES=dict(ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA=False)) + ) + def test_disabled(self, settings): + """ + Ensure that student_view_data does not return html if the ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA feature flag + is not set. + """ + descriptor = Mock() + field_data = DictFieldData({'data': '

Some HTML

'}) + module_system = get_test_system() + module = HtmlModule(descriptor, module_system, field_data, Mock()) + + with override_settings(**settings): + self.assertEqual(module.student_view_data(), dict( + enabled=False, + message='To enable, set FEATURES["ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA"]', + )) + + @ddt.data( + '

Some content

', # Valid HTML + '', + None, + '

Some contentalert()', # Does not escape tags + '', # Images allowed + 'short string ' * 100, # May contain long strings + ) + @override_settings(FEATURES=dict(ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA=True)) + def test_common_values(self, html): + """ + Ensure that student_view_data will return HTML data when enabled, + can handle likely input, + and doesn't modify the HTML in any way. + + This means that it does NOT protect against XSS, escape HTML tags, etc. + + Note that the %%USER_ID%% substitution is tested below. + """ + descriptor = Mock() + field_data = DictFieldData({'data': html}) + module_system = get_test_system() + module = HtmlModule(descriptor, module_system, field_data, Mock()) + self.assertEqual(module.student_view_data(), dict(enabled=True, html=html)) + + class HtmlModuleSubstitutionTestCase(unittest.TestCase): descriptor = Mock() diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index 5c518fa7e2..70a6e49182 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -22,7 +22,7 @@ class TestBlocksView(SharedModuleStoreTestCase): Test class for BlocksView """ requested_fields = ['graded', 'format', 'student_view_multi_device', 'children', 'not_a_field', 'due'] - BLOCK_TYPES_WITH_STUDENT_VIEW_DATA = ['video', 'discussion'] + BLOCK_TYPES_WITH_STUDENT_VIEW_DATA = ['video', 'discussion', 'html'] @classmethod def setUpClass(cls): diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py index b37ec88e80..f20e76c40a 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py @@ -44,7 +44,7 @@ class TestStudentViewTransformer(ModuleStoreTestCase): # verify html data html_block_key = self.course_key.make_usage_key('html', 'toyhtml') - self.assertIsNone( + self.assertIsNotNone( self.block_structure.get_transformer_block_field( html_block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_DATA, ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 61787e1077..1477a1b429 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -413,6 +413,9 @@ FEATURES = { # Set to enable Enterprise integration 'ENABLE_ENTERPRISE_INTEGRATION': False, + + # Whether HTML XBlocks/XModules return HTML content with the Course Blocks API student_view_data + 'ENABLE_HTML_XBLOCK_STUDENT_VIEW_DATA': False, } # Settings for the course reviews tool template and identification key, set either to None to disable course reviews From ae15e69a0ad52fec2f146176e418eb2e37f0ecb2 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 18 Oct 2017 14:19:00 +1030 Subject: [PATCH 21/47] Fixes bug with Course Blocks API student_view_data parameter Prior to this change, providing any student_view_data querystring would result in student_view_data returned for all XBlock types. Updates Course Blocks API tests to verify. --- .../transformers/tests/test_student_view.py | 27 ++++++++++++------- .../block_structure/block_structure.py | 2 +- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py index f20e76c40a..ffef8e1e15 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_student_view.py @@ -1,9 +1,9 @@ """ Tests for StudentViewTransformer. """ +import ddt # pylint: disable=protected-access - from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory @@ -11,6 +11,7 @@ from xmodule.modulestore.tests.factories import ToyCourseFactory from ..student_view import StudentViewTransformer +@ddt.ddt class TestStudentViewTransformer(ModuleStoreTestCase): """ Test proper behavior for StudentViewTransformer @@ -21,20 +22,27 @@ class TestStudentViewTransformer(ModuleStoreTestCase): self.course_usage_key = self.store.make_course_usage_key(self.course_key) self.block_structure = BlockStructureFactory.create_from_modulestore(self.course_usage_key, self.store) - def test_transform(self): + @ddt.data( + 'video', 'html', ['video', 'html'], [], + ) + def test_transform(self, requested_student_view_data): # collect phase StudentViewTransformer.collect(self.block_structure) self.block_structure._collect_requested_xblock_fields() # transform phase - StudentViewTransformer('video').transform(usage_info=None, block_structure=self.block_structure) + StudentViewTransformer(requested_student_view_data).transform( + usage_info=None, + block_structure=self.block_structure, + ) - # verify video data + # verify video data returned iff requested video_block_key = self.course_key.make_usage_key('video', 'sample_video') - self.assertIsNotNone( + self.assertEqual( self.block_structure.get_transformer_block_field( video_block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_DATA, - ) + ) is not None, + 'video' in requested_student_view_data ) self.assertFalse( self.block_structure.get_transformer_block_field( @@ -42,12 +50,13 @@ class TestStudentViewTransformer(ModuleStoreTestCase): ) ) - # verify html data + # verify html data returned iff requested html_block_key = self.course_key.make_usage_key('html', 'toyhtml') - self.assertIsNotNone( + self.assertEqual( self.block_structure.get_transformer_block_field( html_block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_DATA, - ) + ) is not None, + 'html' in requested_student_view_data ) self.assertTrue( self.block_structure.get_transformer_block_field( diff --git a/openedx/core/djangoapps/content/block_structure/block_structure.py b/openedx/core/djangoapps/content/block_structure/block_structure.py index 04a823c442..ff14e62f4e 100644 --- a/openedx/core/djangoapps/content/block_structure/block_structure.py +++ b/openedx/core/djangoapps/content/block_structure/block_structure.py @@ -312,7 +312,7 @@ class FieldData(object): if self._is_own_field(field_name): return super(FieldData, self).__delattr__(field_name) else: - delattr(self.fields, field_name) + del self.fields[field_name] def _is_own_field(self, field_name): """ From 5ffe588abee8b892730ea50b3bc1ac6fd790b3f7 Mon Sep 17 00:00:00 2001 From: noraiz-anwar Date: Tue, 31 Oct 2017 16:59:32 +0500 Subject: [PATCH 22/47] add logs to time frame course listing --- cms/djangoapps/contentstore/views/course.py | 64 ++++++++++++------- .../xmodule/modulestore/split_mongo/split.py | 4 -- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index db075a8e21..7de3e008de 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -497,15 +497,26 @@ def course_listing(request): """ List all courses available to the logged in user """ + def _execute_method_and_log_time(func, *args): + """ + Call func passed in method with logging the time it took to complete. + Logging is temporary, we will remove this once we get required information. + """ + start_time = time.time() + output = func(*args) + log.info('[%s] completed in [%f]', func.__name__, (time.time() - start_time)) + return output + optimization_enabled = GlobalStaff().has_user(request.user) and \ WaffleSwitchNamespace(name=WAFFLE_NAMESPACE).is_enabled(u'enable_global_staff_optimization') org = request.GET.get('org', '') if optimization_enabled else None - start_time = time.time() - courses_iter, in_process_course_actions = get_courses_accessible_to_user(request, org) - log.info('get_courses_accessible_to_user completed in [%f]', (time.time() - start_time)) + courses_iter, in_process_course_actions = _execute_method_and_log_time(get_courses_accessible_to_user, request, org) user = request.user - libraries = _accessible_libraries_iter(request.user, org) if LIBRARIES_ENABLED else [] + + libraries = [] + if LIBRARIES_ENABLED: + libraries = _execute_method_and_log_time(_accessible_libraries_iter, request.user, org) def format_in_process_course_view(uca): """ @@ -542,24 +553,35 @@ def course_listing(request): } split_archived = settings.FEATURES.get(u'ENABLE_SEPARATE_ARCHIVED_COURSES', False) - active_courses, archived_courses = _process_courses_list(courses_iter, in_process_course_actions, split_archived) + active_courses, archived_courses = _execute_method_and_log_time( + _process_courses_list, + courses_iter, + in_process_course_actions, + split_archived + ) in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions] - return render_to_response(u'index.html', { - u'courses': active_courses, - u'archived_courses': archived_courses, - u'in_process_course_actions': in_process_course_actions, - u'libraries_enabled': LIBRARIES_ENABLED, - u'libraries': [format_library_for_view(lib) for lib in libraries], - u'show_new_library_button': get_library_creator_status(user), - u'user': user, - u'request_course_creator_url': reverse(u'contentstore.views.request_course_creator'), - u'course_creator_status': _get_course_creator_status(user), - u'rerun_creator_status': GlobalStaff().has_user(user), - u'allow_unicode_course_id': settings.FEATURES.get(u'ALLOW_UNICODE_COURSE_ID', False), - u'allow_course_reruns': settings.FEATURES.get(u'ALLOW_COURSE_RERUNS', True), - u'optimization_enabled': optimization_enabled - }) + response = _execute_method_and_log_time( + render_to_response, + u'index.html', + { + u'courses': active_courses, + u'archived_courses': archived_courses, + u'in_process_course_actions': in_process_course_actions, + u'libraries_enabled': LIBRARIES_ENABLED, + u'libraries': [format_library_for_view(lib) for lib in libraries], + u'show_new_library_button': get_library_creator_status(user), + u'user': user, + u'request_course_creator_url': reverse(u'contentstore.views.request_course_creator'), + u'course_creator_status': _get_course_creator_status(user), + u'rerun_creator_status': GlobalStaff().has_user(user), + u'allow_unicode_course_id': settings.FEATURES.get(u'ALLOW_UNICODE_COURSE_ID', False), + u'allow_course_reruns': settings.FEATURES.get(u'ALLOW_COURSE_RERUNS', True), + u'optimization_enabled': optimization_enabled + } + ) + + return response def _get_rerun_link_for_item(course_key): @@ -670,9 +692,7 @@ def get_courses_accessible_to_user(request, org=None): courses, in_process_course_actions = _accessible_courses_summary_iter(request, org) else: try: - start_time = time.time() courses, in_process_course_actions = _accessible_courses_list_from_groups(request) - log.info('_accessible_courses_list_from_groups completed in [%f]', (time.time() - start_time)) except AccessListFallback: # user have some old groups or there was some error getting courses from django groups # so fallback to iterating through all courses diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index a7e1f1cb36..f723651b59 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -514,7 +514,6 @@ class SplitBulkWriteMixin(BulkOperationsMixin): org_target, course_keys=course_keys) - start_time = time.time() indexes = self._add_indexes_from_active_records( indexes, branch, @@ -522,7 +521,6 @@ class SplitBulkWriteMixin(BulkOperationsMixin): org_target, course_keys=course_keys ) - log.info('Active records traversed in [%f]', (time.time() - start_time)) return indexes @@ -947,14 +945,12 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): from the course_indexes. """ - start_time = time.time() matching_indexes = self.find_matching_course_indexes( branch, search_targets=None, org_target=kwargs.get('org'), course_keys=kwargs.get('course_keys') ) - log.info('Matching indexes fetched in [%f]', (time.time() - start_time)) # collect ids and then query for those version_guids = [] From 79fe2e626046b470ceb2ddedf07d945fdb33ccf7 Mon Sep 17 00:00:00 2001 From: bmedx Date: Fri, 27 Oct 2017 10:23:05 -0400 Subject: [PATCH 23/47] CMS management command cleanup for Django 1.11 --- .../management/commands/clean_cert_name.py | 10 ++- .../management/commands/cleanup_assets.py | 12 +-- .../management/commands/clone_course.py | 28 ++++--- .../management/commands/create_course.py | 60 +++++++------- .../management/commands/delete_course.py | 5 +- .../management/commands/delete_orphans.py | 12 +-- .../management/commands/edit_course_tabs.py | 79 +++++++++++-------- .../commands/empty_asset_trashcan.py | 16 ++-- .../management/commands/export.py | 3 +- .../management/commands/export_all_courses.py | 40 +++++----- .../management/commands/export_olx.py | 7 +- .../management/commands/fix_not_found.py | 2 +- .../management/commands/force_publish.py | 18 ++--- .../management/commands/generate_courses.py | 3 +- .../management/commands/git_export.py | 31 +++----- .../management/commands/import.py | 2 +- .../management/commands/migrate_to_split.py | 37 ++++----- .../management/commands/populate_creators.py | 32 ++++---- .../management/commands/reindex_course.py | 46 +++++------ .../management/commands/reindex_library.py | 25 +++--- .../commands/restore_asset_from_trashcan.py | 8 +- .../commands/tests/test_reindex_courses.py | 19 ++--- .../contentstore/management/commands/xlint.py | 29 ++++--- 23 files changed, 267 insertions(+), 257 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/clean_cert_name.py b/cms/djangoapps/contentstore/management/commands/clean_cert_name.py index 923e38c633..d291726eda 100644 --- a/cms/djangoapps/contentstore/management/commands/clean_cert_name.py +++ b/cms/djangoapps/contentstore/management/commands/clean_cert_name.py @@ -4,6 +4,8 @@ erroneous certificate names. """ from collections import namedtuple +from six.moves import input +from six import text_type from django.core.management.base import BaseCommand @@ -150,10 +152,10 @@ class Command(BaseCommand): """ headers = ["Course Key", "cert_name_short", "cert_name_short", "Should clean?"] col_widths = [ - max(len(unicode(result[col])) for result in results + [headers]) + max(len(text_type(result[col])) for result in results + [headers]) for col in range(len(results[0])) ] - id_format = "{{:>{}}} |".format(len(unicode(len(results)))) + id_format = "{{:>{}}} |".format(len(text_type(len(results)))) col_format = "| {{:>{}}} |" self.stdout.write(id_format.format(""), ending='') @@ -165,7 +167,7 @@ class Command(BaseCommand): for idx, result in enumerate(results): self.stdout.write(id_format.format(idx), ending='') for col, width in zip(result, col_widths): - self.stdout.write(col_format.format(width).format(unicode(col)), ending='') + self.stdout.write(col_format.format(width).format(text_type(col)), ending='') self.stdout.write("") def _commit(self, results): @@ -191,7 +193,7 @@ class Command(BaseCommand): while True: self._display(results) - command = raw_input("|commit|quit: ").strip() + command = input("|commit|quit: ").strip() if command == 'quit': return diff --git a/cms/djangoapps/contentstore/management/commands/cleanup_assets.py b/cms/djangoapps/contentstore/management/commands/cleanup_assets.py index c3524c51c6..2044fde7a3 100644 --- a/cms/djangoapps/contentstore/management/commands/cleanup_assets.py +++ b/cms/djangoapps/contentstore/management/commands/cleanup_assets.py @@ -24,17 +24,17 @@ class Command(BaseCommand): content_store = contentstore() success = False - log.info(u"-" * 80) - log.info(u"Cleaning up assets for all courses") + log.info("-" * 80) + log.info("Cleaning up assets for all courses") try: # Remove all redundant Mac OS metadata files assets_deleted = content_store.remove_redundant_content_for_courses() success = True except Exception as err: - log.info(u"=" * 30 + u"> failed to cleanup") - log.info(u"Error:") + log.info("=" * 30 + u"> failed to cleanup") + log.info("Error:") log.info(err) if success: - log.info(u"=" * 80) - log.info(u"Total number of assets deleted: {0}".format(assets_deleted)) + log.info("=" * 80) + log.info("Total number of assets deleted: {0}".format(assets_deleted)) diff --git a/cms/djangoapps/contentstore/management/commands/clone_course.py b/cms/djangoapps/contentstore/management/commands/clone_course.py index b8a8c9a752..fb7a39c9ba 100644 --- a/cms/djangoapps/contentstore/management/commands/clone_course.py +++ b/cms/djangoapps/contentstore/management/commands/clone_course.py @@ -1,7 +1,9 @@ """ Script for cloning a course """ -from django.core.management.base import BaseCommand, CommandError +from __future__ import print_function + +from django.core.management.base import BaseCommand from opaque_keys.edx.keys import CourseKey from student.roles import CourseInstructorRole, CourseStaffRole @@ -13,24 +15,30 @@ from xmodule.modulestore.django import modulestore # To run from command line: ./manage.py cms clone_course --settings=dev master/300/cough edx/111/foo # class Command(BaseCommand): - """Clone a MongoDB-backed course to another location""" + """ + Clone a MongoDB-backed course to another location + """ help = 'Clone a MongoDB backed course to another location' - def handle(self, *args, **options): - "Execute the command" - if len(args) != 2: - raise CommandError("clone requires 2 arguments: ") + def add_arguments(self, parser): + parser.add_argument('source_course_id', help='Course ID to copy from') + parser.add_argument('dest_course_id', help='Course ID to copy to') - source_course_id = CourseKey.from_string(args[0]) - dest_course_id = CourseKey.from_string(args[1]) + def handle(self, *args, **options): + """ + Execute the command + """ + + source_course_id = CourseKey.from_string(options['source_course_id']) + dest_course_id = CourseKey.from_string(options['dest_course_id']) mstore = modulestore() - print "Cloning course {0} to {1}".format(source_course_id, dest_course_id) + print("Cloning course {0} to {1}".format(source_course_id, dest_course_id)) with mstore.bulk_operations(dest_course_id): if mstore.clone_course(source_course_id, dest_course_id, ModuleStoreEnum.UserID.mgmt_command): - print "copying User permissions..." + print("copying User permissions...") # purposely avoids auth.add_user b/c it doesn't have a caller to authorize CourseInstructorRole(dest_course_id).add_users( *CourseInstructorRole(source_course_id).users_with_role() diff --git a/cms/djangoapps/contentstore/management/commands/create_course.py b/cms/djangoapps/contentstore/management/commands/create_course.py index 5908990a09..b1ac53e085 100644 --- a/cms/djangoapps/contentstore/management/commands/create_course.py +++ b/cms/djangoapps/contentstore/management/commands/create_course.py @@ -1,6 +1,8 @@ """ Django management command to create a course in a specific modulestore """ +from six import text_type + from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError @@ -9,6 +11,9 @@ from contentstore.views.course import create_new_course_in_store from xmodule.modulestore import ModuleStoreEnum +MODULESTORE_CHOICES = (ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) + + class Command(BaseCommand): """ Create a course in a specific modulestore. @@ -16,45 +21,36 @@ class Command(BaseCommand): # can this query modulestore for the list of write accessible stores or does that violate command pattern? help = "Create a course in one of {}".format([ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split]) - args = "modulestore user org course run" - def parse_args(self, *args): + def add_arguments(self, parser): + parser.add_argument('modulestore', + choices=MODULESTORE_CHOICES, + help="Modulestore must be one of {}".format(MODULESTORE_CHOICES)) + parser.add_argument('user', + help="The instructor's email address or integer ID.") + parser.add_argument('org', + help="The organization to create the course within.") + parser.add_argument('course', + help="The name of the course.") + parser.add_argument('run', + help="The name of the course run.") + + def parse_args(self, **options): """ Return a tuple of passed in values for (modulestore, user, org, course, run). """ - if len(args) != 5: - raise CommandError( - "create_course requires 5 arguments: " - "a modulestore, user, org, course, run. Modulestore is one of {}".format( - [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split] - ) - ) - - if args[0] not in [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split]: - raise CommandError( - "Modulestore (first arg) must be one of {}".format( - [ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split] - ) - ) - storetype = args[0] - try: - user = user_from_str(args[1]) + user = user_from_str(options['user']) except User.DoesNotExist: - raise CommandError( - "No user {user} found: expected args are {args}".format( - user=args[1], - args=self.args, - ), - ) + raise CommandError("No user {user} found.".format(user=options['user'])) - org = args[2] - course = args[3] - run = args[4] - - return storetype, user, org, course, run + return options['modulestore'], user, options['org'], options['course'], options['run'] def handle(self, *args, **options): - storetype, user, org, course, run = self.parse_args(*args) + storetype, user, org, course, run = self.parse_args(**options) + + if storetype == ModuleStoreEnum.Type.mongo: + self.stderr.write("WARNING: The 'Old Mongo' store is deprecated. New courses should be added to split.") + new_course = create_new_course_in_store(storetype, user, org, course, run, {}) - self.stdout.write(u"Created {}".format(unicode(new_course.id))) + self.stdout.write(u"Created {}".format(text_type(new_course.id))) diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index c3502d5467..61f99b1dab 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -1,3 +1,6 @@ +from __future__ import print_function +from six import text_type + from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -54,7 +57,7 @@ class Command(BaseCommand): def handle(self, *args, **options): try: # a course key may have unicode chars in it - course_key = unicode(options['course_key'], 'utf8') + course_key = text_type(options['course_key'], 'utf8') course_key = CourseKey.from_string(course_key) except InvalidKeyError: raise CommandError('Invalid course_key: {}'.format(options['course_key'])) diff --git a/cms/djangoapps/contentstore/management/commands/delete_orphans.py b/cms/djangoapps/contentstore/management/commands/delete_orphans.py index c765cda23a..146f283ab4 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_orphans.py +++ b/cms/djangoapps/contentstore/management/commands/delete_orphans.py @@ -1,4 +1,6 @@ """Script for deleting orphans""" +from __future__ import print_function + from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -26,15 +28,15 @@ class Command(BaseCommand): raise CommandError("Invalid course key.") if options['commit']: - print 'Deleting orphans from the course:' + print('Deleting orphans from the course:') deleted_items = _delete_orphans( course_key, ModuleStoreEnum.UserID.mgmt_command, options['commit'] ) - print "Success! Deleted the following orphans from the course:" - print "\n".join(deleted_items) + print("Success! Deleted the following orphans from the course:") + print("\n".join(deleted_items)) else: - print 'Dry run. The following orphans would have been deleted from the course:' + print('Dry run. The following orphans would have been deleted from the course:') deleted_items = _delete_orphans( course_key, ModuleStoreEnum.UserID.mgmt_command, options['commit'] ) - print "\n".join(deleted_items) + print("\n".join(deleted_items)) diff --git a/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py index 9d3891f7f7..04cd22fb0b 100644 --- a/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py +++ b/cms/djangoapps/contentstore/management/commands/edit_course_tabs.py @@ -6,7 +6,8 @@ # Run it this way: # ./manage.py cms --settings dev edit_course_tabs --course Stanford/CS99/2013_spring # -from optparse import make_option +from __future__ import print_function + from django.core.management.base import BaseCommand, CommandError from opaque_keys.edx.keys import CourseKey @@ -18,10 +19,16 @@ from .prompt import query_yes_no def print_course(course): "Prints out the course id and a numbered list of tabs." - print course.id - print 'num type name' - for index, item in enumerate(course.tabs): - print index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"' + try: + print(course.id) + print('num type name') + for index, item in enumerate(course.tabs): + print(index + 1, '"' + item.get('type') + '"', '"' + item.get('name', '') + '"') + # If a course is bad we will get an error descriptor here, dump it and die instead of + # just sending up the error that .id doesn't exist. + except AttributeError: + print(course) + raise # course.tabs looks like this @@ -42,48 +49,50 @@ As a first step, run the command with a courseid like this: This will print the existing tabs types and names. Then run the command again, adding --insert or --delete to edit the list. """ - # Making these option objects separately, so can refer to their .help below - course_option = make_option('--course', - action='store', - dest='course', - default=False, - help='--course required, e.g. Stanford/CS99/2013_spring') - delete_option = make_option('--delete', - action='store_true', - dest='delete', - default=False, - help='--delete ') - insert_option = make_option('--insert', - action='store_true', - dest='insert', - default=False, - help='--insert , e.g. 2 "course_info" "Course Info"') - option_list = BaseCommand.option_list + (course_option, delete_option, insert_option) + course_help = '--course required, e.g. Stanford/CS99/2013_spring' + delete_help = '--delete ' + insert_help = '--insert , e.g. 4 "course_info" "Course Info"' + + def add_arguments(self, parser): + parser.add_argument('--course', + dest='course', + default=False, + required=True, + help=self.course_help) + parser.add_argument('--delete', + dest='delete', + default=False, + nargs=1, + help=self.delete_help) + parser.add_argument('--insert', + dest='insert', + default=False, + nargs=3, + help=self.insert_help, + ) def handle(self, *args, **options): - if not options['course']: - raise CommandError(Command.course_option.help) - course = get_course_by_id(CourseKey.from_string(options['course'])) - print 'Warning: this command directly edits the list of course tabs in mongo.' - print 'Tabs before any changes:' + print('Warning: this command directly edits the list of course tabs in mongo.') + print('Tabs before any changes:') print_course(course) try: if options['delete']: - if len(args) != 1: - raise CommandError(Command.delete_option.help) - num = int(args[0]) + num = int(options['delete'][0]) + if num < 3: + raise CommandError("Tabs 1 and 2 cannot be changed.") + if query_yes_no('Deleting tab {0} Confirm?'.format(num), default='no'): tabs.primitive_delete(course, num - 1) # -1 for 0-based indexing elif options['insert']: - if len(args) != 3: - raise CommandError(Command.insert_option.help) - num = int(args[0]) - tab_type = args[1] - name = args[2] + num, tab_type, name = options['insert'] + num = int(num) + if num < 3: + raise CommandError("Tabs 1 and 2 cannot be changed.") + if query_yes_no('Inserting tab {0} "{1}" "{2}" Confirm?'.format(num, tab_type, name), default='no'): tabs.primitive_insert(course, num - 1, tab_type, name) # -1 as above except ValueError as e: diff --git a/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py index 3c7288552c..952164fb0a 100644 --- a/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py +++ b/cms/djangoapps/contentstore/management/commands/empty_asset_trashcan.py @@ -8,16 +8,18 @@ from .prompt import query_yes_no class Command(BaseCommand): - help = '''Empty the trashcan. Can pass an optional course_id to limit the damage.''' + help = 'Empty the trashcan. Can pass an optional course_id to limit the damage.' + + def add_arguments(self, parser): + parser.add_argument('course_id', + help='Course ID to empty, leave off to empty for all courses', + nargs='?') def handle(self, *args, **options): - if len(args) != 1 and len(args) != 0: - raise CommandError("empty_asset_trashcan requires one or no arguments: ||") - - if len(args) == 1: - course_ids = [CourseKey.from_string(args[0])] + if options['course_id']: + course_ids = [CourseKey.from_string(options['course_id'])] else: course_ids = [course.id for course in modulestore().get_courses()] - if query_yes_no("Emptying trashcan. Confirm?", default="no"): + if query_yes_no("Emptying {} trashcan(s). Confirm?".format(len(course_ids)), default="no"): empty_asset_trashcan(course_ids) diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index ea351658ee..72dba96c76 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -1,6 +1,7 @@ """ Script for exporting courseware from Mongo to a tar.gz file """ +from __future__ import print_function import os from django.core.management.base import BaseCommand, CommandError @@ -37,7 +38,7 @@ class Command(BaseCommand): output_path = options['output_path'] - print "Exporting course id = {0} to {1}".format(course_key, output_path) + print("Exporting course id = {0} to {1}".format(course_key, output_path)) if not output_path.endswith('/'): output_path += '/' diff --git a/cms/djangoapps/contentstore/management/commands/export_all_courses.py b/cms/djangoapps/contentstore/management/commands/export_all_courses.py index 54f6b11f03..6f77ec7a99 100644 --- a/cms/djangoapps/contentstore/management/commands/export_all_courses.py +++ b/cms/djangoapps/contentstore/management/commands/export_all_courses.py @@ -1,7 +1,10 @@ """ Script for exporting all courseware from Mongo to a directory and listing the courses which failed to export """ -from django.core.management.base import BaseCommand, CommandError +from __future__ import print_function +from six import text_type + +from django.core.management.base import BaseCommand from xmodule.contentstore.django import contentstore from xmodule.modulestore.django import modulestore @@ -14,23 +17,22 @@ class Command(BaseCommand): """ help = 'Export all courses from mongo to the specified data directory and list the courses which failed to export' + def add_arguments(self, parser): + parser.add_argument('output_path') + def handle(self, *args, **options): """ Execute the command """ - if len(args) != 1: - raise CommandError("export requires one argument: ") + courses, failed_export_courses = export_courses_to_output_path(options['output_path']) - output_path = args[0] - courses, failed_export_courses = export_courses_to_output_path(output_path) - - print "=" * 80 - print u"=" * 30 + u"> Export summary" - print u"Total number of courses to export: {0}".format(len(courses)) - print u"Total number of courses which failed to export: {0}".format(len(failed_export_courses)) - print u"List of export failed courses ids:" - print u"\n".join(failed_export_courses) - print "=" * 80 + print("=" * 80) + print("=" * 30 + "> Export summary") + print("Total number of courses to export: {0}".format(len(courses))) + print("Total number of courses which failed to export: {0}".format(len(failed_export_courses))) + print("List of export failed courses ids:") + print("\n".join(failed_export_courses)) + print("=" * 80) def export_courses_to_output_path(output_path): @@ -46,15 +48,15 @@ def export_courses_to_output_path(output_path): failed_export_courses = [] for course_id in course_ids: - print u"-" * 80 - print u"Exporting course id = {0} to {1}".format(course_id, output_path) + print("-" * 80) + print("Exporting course id = {0} to {1}".format(course_id, output_path)) try: course_dir = course_id.to_deprecated_string().replace('/', '...') export_course_to_xml(module_store, content_store, course_id, root_dir, course_dir) except Exception as err: # pylint: disable=broad-except - failed_export_courses.append(unicode(course_id)) - print u"=" * 30 + u"> Oops, failed to export {0}".format(course_id) - print u"Error:" - print err + failed_export_courses.append(text_type(course_id)) + print("=" * 30 + "> Oops, failed to export {0}".format(course_id)) + print("Error:") + print(err) return courses, failed_export_courses diff --git a/cms/djangoapps/contentstore/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index 72ac1f1f26..4d02aeefd7 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -12,7 +12,6 @@ At present, it differs from Studio exports in several ways: * The top-level directory in the resulting tarball is a "safe" (i.e. ascii) version of the course_key, rather than the word "course". * It only supports the export of courses. It does not export libraries. - """ import os @@ -34,17 +33,16 @@ from xmodule.modulestore.xml_exporter import export_course_to_xml class Command(BaseCommand): """ Export a course to XML. The output is compressed as a tar.gz file. - """ help = dedent(__doc__).strip() def add_arguments(self, parser): parser.add_argument('course_id') - parser.add_argument('--output', default=None) + parser.add_argument('--output') def handle(self, *args, **options): - course_id = options['course_id'] + try: course_key = CourseKey.from_string(course_id) except InvalidKeyError: @@ -54,6 +52,7 @@ class Command(BaseCommand): filename = options['output'] pipe_results = False + if filename is None: filename = mktemp() pipe_results = True diff --git a/cms/djangoapps/contentstore/management/commands/fix_not_found.py b/cms/djangoapps/contentstore/management/commands/fix_not_found.py index ddf00f9c97..7c41230417 100644 --- a/cms/djangoapps/contentstore/management/commands/fix_not_found.py +++ b/cms/djangoapps/contentstore/management/commands/fix_not_found.py @@ -20,7 +20,7 @@ class Command(BaseCommand): def handle(self, *args, **options): """Execute the command""" - course_id = options.get('course_id', None) + course_id = options['course_id'] course_key = CourseKey.from_string(course_id) # for now only support on split mongo diff --git a/cms/djangoapps/contentstore/management/commands/force_publish.py b/cms/djangoapps/contentstore/management/commands/force_publish.py index 6dcea6f95f..642e87db21 100644 --- a/cms/djangoapps/contentstore/management/commands/force_publish.py +++ b/cms/djangoapps/contentstore/management/commands/force_publish.py @@ -44,7 +44,7 @@ class Command(BaseCommand): owning_store = modulestore()._get_modulestore_for_courselike(course_key) # pylint: disable=protected-access if hasattr(owning_store, 'force_publish_course'): versions = get_course_versions(options['course_key']) - print "Course versions : {0}".format(versions) + print("Course versions : {0}".format(versions)) if options['commit']: if query_yes_no("Are you sure to publish the {0} course forcefully?".format(course_key), default="no"): @@ -55,20 +55,20 @@ class Command(BaseCommand): if updated_versions: # if publish and draft were different if versions['published-branch'] != versions['draft-branch']: - print "Success! Published the course '{0}' forcefully.".format(course_key) - print "Updated course versions : \n{0}".format(updated_versions) + print("Success! Published the course '{0}' forcefully.".format(course_key)) + print("Updated course versions : \n{0}".format(updated_versions)) else: - print "Course '{0}' is already in published state.".format(course_key) + print("Course '{0}' is already in published state.".format(course_key)) else: - print "Error! Could not publish course {0}.".format(course_key) + print("Error! Could not publish course {0}.".format(course_key)) else: # if publish and draft were different if versions['published-branch'] != versions['draft-branch']: - print "Dry run. Following would have been changed : " - print "Published branch version {0} changed to draft branch version {1}".format( - versions['published-branch'], versions['draft-branch'] + print("Dry run. Following would have been changed : ") + print("Published branch version {0} changed to draft branch version {1}".format( + versions['published-branch'], versions['draft-branch']) ) else: - print "Dry run. Course '{0}' is already in published state.".format(course_key) + print("Dry run. Course '{0}' is already in published state.".format(course_key)) else: raise CommandError("The owning modulestore does not support this command.") diff --git a/cms/djangoapps/contentstore/management/commands/generate_courses.py b/cms/djangoapps/contentstore/management/commands/generate_courses.py index 475e22801d..19bd29858f 100644 --- a/cms/djangoapps/contentstore/management/commands/generate_courses.py +++ b/cms/djangoapps/contentstore/management/commands/generate_courses.py @@ -3,6 +3,7 @@ Django management command to generate a test course from a course config json """ import json import logging +from six import text_type from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError @@ -57,7 +58,7 @@ class Command(BaseCommand): # Create the course try: new_course = create_new_course_in_store("split", user, org, num, run, fields) - logger.info("Created {}".format(unicode(new_course.id))) + logger.info("Created {}".format(text_type(new_course.id))) except DuplicateCourseError: logger.warning("Course already exists for %s, %s, %s", org, num, run) diff --git a/cms/djangoapps/contentstore/management/commands/git_export.py b/cms/djangoapps/contentstore/management/commands/git_export.py index bbf9d5a2a9..1fe60068fb 100644 --- a/cms/djangoapps/contentstore/management/commands/git_export.py +++ b/cms/djangoapps/contentstore/management/commands/git_export.py @@ -14,7 +14,7 @@ attribute is set and the FEATURE['ENABLE_EXPORT_GIT'] is set. """ import logging -from optparse import make_option +from six import text_type from django.core.management.base import BaseCommand, CommandError from django.utils.translation import ugettext as _ @@ -31,41 +31,34 @@ class Command(BaseCommand): """ Take a course from studio and export it to a git repository. """ - - option_list = BaseCommand.option_list + ( - make_option('--username', '-u', dest='user', - help=('Specify a username from LMS/Studio to be used ' - 'as the commit author.')), - make_option('--repo_dir', '-r', dest='repo', - help='Specify existing git repo directory.'), - ) - help = _('Take the specified course and attempt to ' 'export it to a git repository\n. Course directory ' 'must already be a git repository. Usage: ' ' git_export ') + def add_arguments(self, parser): + parser.add_argument('course_loc') + parser.add_argument('git_url') + parser.add_argument('--username', '-u', dest='user', + help='Specify a username from LMS/Studio to be used as the commit author.') + parser.add_argument('--repo_dir', '-r', dest='repo', help='Specify existing git repo directory.') + def handle(self, *args, **options): """ Checks arguments and runs export function if they are good """ - - if len(args) != 2: - raise CommandError('This script requires exactly two arguments: ' - 'course_loc and git_url') - # Rethrow GitExportError as CommandError for SystemExit try: - course_key = CourseKey.from_string(args[0]) + course_key = CourseKey.from_string(options['course_loc']) except InvalidKeyError: - raise CommandError(unicode(GitExportError.BAD_COURSE)) + raise CommandError(text_type(GitExportError.BAD_COURSE)) try: git_export_utils.export_to_git( course_key, - args[1], + options['git_url'], options.get('user', ''), options.get('rdir', None) ) except git_export_utils.GitExportError as ex: - raise CommandError(unicode(ex.message)) + raise CommandError(text_type(ex.message)) diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 9cd6467033..123c9c4173 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -3,7 +3,7 @@ Script for importing courseware from XML format """ from optparse import make_option -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django_comment_common.utils import are_permissions_roles_seeded, seed_permissions_roles from xmodule.contentstore.django import contentstore diff --git a/cms/djangoapps/contentstore/management/commands/migrate_to_split.py b/cms/djangoapps/contentstore/management/commands/migrate_to_split.py index 3613a26c5e..2a3909edb3 100644 --- a/cms/djangoapps/contentstore/management/commands/migrate_to_split.py +++ b/cms/djangoapps/contentstore/management/commands/migrate_to_split.py @@ -18,41 +18,34 @@ class Command(BaseCommand): Migrate a course from old-Mongo to split-Mongo. It reuses the old course id except where overridden. """ - help = "Migrate a course from old-Mongo to split-Mongo. The new org, course, and run will default to the old one unless overridden" - args = "course_key email " + help = "Migrate a course from old-Mongo to split-Mongo. The new org, course, and run will " \ + "default to the old one unless overridden." - def parse_args(self, *args): + def add_arguments(self, parser): + parser.add_argument('course_key') + parser.add_argument('email') + parser.add_argument('--org', help='New org to migrate to.') + parser.add_argument('--course', help='New course key to migrate to.') + parser.add_argument('--run', help='New run to migrate to.') + + def parse_args(self, **options): """ Return a 5-tuple of passed in values for (course_key, user, org, course, run). """ - if len(args) < 2: - raise CommandError( - "migrate_to_split requires at least two arguments: " - "a course_key and a user identifier (email or ID)" - ) - try: - course_key = CourseKey.from_string(args[0]) + course_key = CourseKey.from_string(options['course_key']) except InvalidKeyError: raise CommandError("Invalid location string") try: - user = user_from_str(args[1]) + user = user_from_str(options['email']) except User.DoesNotExist: - raise CommandError("No user found identified by {}".format(args[1])) + raise CommandError("No user found identified by {}".format(options['email'])) - org = course = run = None - try: - org = args[2] - course = args[3] - run = args[4] - except IndexError: - pass - - return course_key, user.id, org, course, run + return course_key, user.id, options['org'], options['course'], options['run'] def handle(self, *args, **options): - course_key, user, org, course, run = self.parse_args(*args) + course_key, user, org, course, run = self.parse_args(**options) migrator = SplitMigrator( source_modulestore=modulestore(), diff --git a/cms/djangoapps/contentstore/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py index fa5a848028..4822f9089f 100644 --- a/cms/djangoapps/contentstore/management/commands/populate_creators.py +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -2,6 +2,8 @@ Script for granting existing course instructors course creator privileges. This script is only intended to be run once on a given environment. + +To run: ./manage.py cms populate_creators --settings=dev """ from django.contrib.auth.models import User from django.core.management.base import BaseCommand @@ -11,9 +13,6 @@ from course_creators.views import add_user_with_status_granted, add_user_with_st from student.roles import CourseInstructorRole, CourseStaffRole -#------------ to run: ./manage.py cms populate_creators --settings=dev - - class Command(BaseCommand): """ Script for granting existing course instructors course creator privileges. @@ -35,23 +34,24 @@ class Command(BaseCommand): # the admin user will already exist. admin = User.objects.get(username=username, email=email) - for user in get_users_with_role(CourseInstructorRole.ROLE): - add_user_with_status_granted(admin, user) + try: + for user in get_users_with_role(CourseInstructorRole.ROLE): + add_user_with_status_granted(admin, user) - # Some users will be both staff and instructors. Those folks have been - # added with status granted above, and add_user_with_status_unrequested - # will not try to add them again if they already exist in the course creator database. - for user in get_users_with_role(CourseStaffRole.ROLE): - add_user_with_status_unrequested(user) + # Some users will be both staff and instructors. Those folks have been + # added with status granted above, and add_user_with_status_unrequested + # will not try to add them again if they already exist in the course creator database. + for user in get_users_with_role(CourseStaffRole.ROLE): + add_user_with_status_unrequested(user) - # There could be users who are not in either staff or instructor (they've - # never actually done anything in Studio). I plan to add those as unrequested - # when they first go to their dashboard. - - admin.delete() + # There could be users who are not in either staff or instructor (they've + # never actually done anything in Studio). I plan to add those as unrequested + # when they first go to their dashboard. + finally: + # Let's not leave this lying around. + admin.delete() -#============================================================================================================= # Because these are expensive and far-reaching, I moved them here def get_users_with_role(role_prefix): """ diff --git a/cms/djangoapps/contentstore/management/commands/reindex_course.py b/cms/djangoapps/contentstore/management/commands/reindex_course.py index 796783ca57..4328f2e968 100644 --- a/cms/djangoapps/contentstore/management/commands/reindex_course.py +++ b/cms/djangoapps/contentstore/management/commands/reindex_course.py @@ -1,6 +1,5 @@ """ Management command to update courses' search index """ import logging -from optparse import make_option from textwrap import dedent from django.core.management import BaseCommand, CommandError @@ -22,32 +21,25 @@ class Command(BaseCommand): Examples: - ./manage.py reindex_course - reindexes courses with keys course_id_1 and course_id_2 + ./manage.py reindex_course ... - reindexes courses with provided keys ./manage.py reindex_course --all - reindexes all available courses ./manage.py reindex_course --setup - reindexes all courses for devstack setup """ help = dedent(__doc__) - can_import_settings = True - - args = "" - - all_option = make_option('--all', - action='store_true', - dest='all', - default=False, - help='Reindex all courses') - - setup_option = make_option('--setup', - action='store_true', - dest='setup', - default=False, - help='Reindex all courses on developers stack setup') - - option_list = BaseCommand.option_list + (all_option, setup_option) - CONFIRMATION_PROMPT = u"Re-indexing all courses might be a time consuming operation. Do you want to continue?" + def add_arguments(self, parser): + parser.add_argument('course_ids', + nargs='*', + metavar='course_id') + parser.add_argument('--all', + action='store_true', + help='Reindex all courses') + parser.add_argument('--setup', + action='store_true', + help='Reindex all courses on developers stack setup') + def _parse_course_key(self, raw_value): """ Parses course key from string """ try: @@ -65,12 +57,14 @@ class Command(BaseCommand): By convention set by Django developers, this method actually executes command's actions. So, there could be no better docstring than emphasize this once again. """ - all_option = options.get('all', False) - setup_option = options.get('setup', False) + course_ids = options['course_ids'] + all_option = options['all'] + setup_option = options['setup'] index_all_courses_option = all_option or setup_option - if len(args) == 0 and not index_all_courses_option: - raise CommandError(u"reindex_course requires one or more arguments: ") + if (not len(course_ids) and not index_all_courses_option) or \ + (len(course_ids) and index_all_courses_option): + raise CommandError("reindex_course requires one or more s OR the --all or --setup flags.") store = modulestore() @@ -82,7 +76,7 @@ class Command(BaseCommand): # try getting the ElasticSearch engine searcher = SearchEngine.get_search_engine(index_name) except exceptions.ElasticsearchException as exc: - logging.exception('Search Engine error - %s', unicode(exc)) + logging.exception('Search Engine error - %s', exc) return index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access @@ -108,7 +102,7 @@ class Command(BaseCommand): return else: # in case course keys are provided as arguments - course_keys = map(self._parse_course_key, args) + course_keys = map(self._parse_course_key, course_ids) for course_key in course_keys: CoursewareSearchIndexer.do_course_reindex(store, course_key) diff --git a/cms/djangoapps/contentstore/management/commands/reindex_library.py b/cms/djangoapps/contentstore/management/commands/reindex_library.py index 596373ffea..50d7a70d0c 100644 --- a/cms/djangoapps/contentstore/management/commands/reindex_library.py +++ b/cms/djangoapps/contentstore/management/commands/reindex_library.py @@ -1,5 +1,5 @@ """ Management command to update libraries' search index """ -from optparse import make_option +from __future__ import print_function from textwrap import dedent from django.core.management import BaseCommand, CommandError @@ -22,21 +22,17 @@ class Command(BaseCommand): ./manage.py reindex_library --all - reindexes all available libraries """ help = dedent(__doc__) - can_import_settings = True + CONFIRMATION_PROMPT = u"Reindexing all libraries might be a time consuming operation. Do you want to continue?" - args = "" - - option_list = BaseCommand.option_list + ( - make_option( + def add_arguments(self, parser): + parser.add_argument('library_ids', nargs='*') + parser.add_argument( '--all', action='store_true', dest='all', - default=False, help='Reindex all libraries' - ),) - - CONFIRMATION_PROMPT = u"Reindexing all libraries might be a time consuming operation. Do you want to continue?" + ) def _parse_library_key(self, raw_value): """ Parses library key from string """ @@ -52,18 +48,19 @@ class Command(BaseCommand): By convention set by django developers, this method actually executes command's actions. So, there could be no better docstring than emphasize this once again. """ - if len(args) == 0 and not options.get('all', False): - raise CommandError(u"reindex_library requires one or more arguments: ") + if (not options['library_ids'] and not options['all']) or (options['library_ids'] and options['all']): + raise CommandError(u"reindex_library requires one or more s or the --all flag.") store = modulestore() - if options.get('all', False): + if options['all']: if query_yes_no(self.CONFIRMATION_PROMPT, default="no"): library_keys = [library.location.library_key.replace(branch=None) for library in store.get_libraries()] else: return else: - library_keys = map(self._parse_library_key, args) + library_keys = map(self._parse_library_key, options['library_ids']) for library_key in library_keys: + print("Indexing library {}".format(library_key)) LibrarySearchIndexer.do_library_reindex(store, library_key) diff --git a/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py index fa314ddbd3..ca8de0ceb7 100644 --- a/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py +++ b/cms/djangoapps/contentstore/management/commands/restore_asset_from_trashcan.py @@ -6,8 +6,8 @@ from xmodule.contentstore.utils import restore_asset_from_trashcan class Command(BaseCommand): help = '''Restore a deleted asset from the trashcan back to it's original course''' - def handle(self, *args, **options): - if len(args) != 1 and len(args) != 0: - raise CommandError("restore_asset_from_trashcan requires one argument: ") + def add_arguments(self, parser): + parser.add_argument('location') - restore_asset_from_trashcan(args[0]) + def handle(self, *args, **options): + restore_asset_from_trashcan(options['location']) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py index 71db8f4f07..3d3d99b91a 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py @@ -2,6 +2,7 @@ import ddt from django.core.management import call_command, CommandError import mock +from six import text_type from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -60,34 +61,34 @@ class TestReindexCourse(ModuleStoreTestCase): def test_given_library_key_raises_command_error(self): """ Test that raises CommandError if library key is passed """ with self.assertRaisesRegexp(CommandError, ".* is not a course key"): - call_command('reindex_course', unicode(self._get_lib_key(self.first_lib))) + call_command('reindex_course', text_type(self._get_lib_key(self.first_lib))) with self.assertRaisesRegexp(CommandError, ".* is not a course key"): - call_command('reindex_course', unicode(self._get_lib_key(self.second_lib))) + call_command('reindex_course', text_type(self._get_lib_key(self.second_lib))) with self.assertRaisesRegexp(CommandError, ".* is not a course key"): call_command( 'reindex_course', - unicode(self.second_course.id), - unicode(self._get_lib_key(self.first_lib)) + text_type(self.second_course.id), + text_type(self._get_lib_key(self.first_lib)) ) def test_given_id_list_indexes_courses(self): """ Test that reindexes courses when given single course key or a list of course keys """ with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \ mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)): - call_command('reindex_course', unicode(self.first_course.id)) + call_command('reindex_course', text_type(self.first_course.id)) self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_course)) patched_index.reset_mock() - call_command('reindex_course', unicode(self.second_course.id)) + call_command('reindex_course', text_type(self.second_course.id)) self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_course)) patched_index.reset_mock() call_command( 'reindex_course', - unicode(self.first_course.id), - unicode(self.second_course.id) + text_type(self.first_course.id), + text_type(self.second_course.id) ) expected_calls = self._build_calls(self.first_course, self.second_course) self.assertEqual(patched_index.mock_calls, expected_calls) @@ -121,4 +122,4 @@ class TestReindexCourse(ModuleStoreTestCase): patched_index.side_effect = SearchIndexingError("message", []) with self.assertRaises(SearchIndexingError): - call_command('reindex_course', unicode(self.second_course.id)) + call_command('reindex_course', text_type(self.second_course.id)) diff --git a/cms/djangoapps/contentstore/management/commands/xlint.py b/cms/djangoapps/contentstore/management/commands/xlint.py index afb7c73980..4eac818538 100644 --- a/cms/djangoapps/contentstore/management/commands/xlint.py +++ b/cms/djangoapps/contentstore/management/commands/xlint.py @@ -1,26 +1,33 @@ """ Verify the structure of courseware as to it's suitability for import """ -from django.core.management.base import BaseCommand, CommandError +from __future__ import print_function +from argparse import REMAINDER + +from django.core.management.base import BaseCommand from xmodule.modulestore.xml_importer import perform_xlint class Command(BaseCommand): - """Verify the structure of courseware as to it's suitability for import""" - help = "Verify the structure of courseware as to it's suitability for import" + """Verify the structure of courseware as to its suitability for import""" + help = """ + Verify the structure of courseware as to its suitability for import. + To run: manage.py cms [...] + """ + + def add_arguments(self, parser): + parser.add_argument('data_dir') + parser.add_argument('source_dirs', nargs=REMAINDER) def handle(self, *args, **options): - "Execute the command" - if len(args) == 0: - raise CommandError("import requires at least one argument: [...]") + """Execute the command""" + + data_dir = options['data_dir'] + source_dirs = options['source_dirs'] - data_dir = args[0] - if len(args) > 1: - source_dirs = args[1:] - else: - source_dirs = None print("Importing. Data_dir={data}, source_dirs={courses}".format( data=data_dir, courses=source_dirs)) + perform_xlint(data_dir, source_dirs, load_error_modules=False) From d5b3db893444af458d196d6655ef494ed6c99e62 Mon Sep 17 00:00:00 2001 From: bmedx Date: Fri, 27 Oct 2017 11:28:48 -0400 Subject: [PATCH 24/47] Fix up tests to support updated management commands --- .../commands/tests/test_create_course.py | 13 ++++------- .../commands/tests/test_git_export.py | 4 ++-- .../commands/tests/test_migrate_to_split.py | 23 +++++++------------ .../commands/tests/test_reindex_courses.py | 2 +- .../commands/tests/test_reindex_library.py | 2 +- 5 files changed, 17 insertions(+), 27 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py index b6a5920f73..70122aa5cb 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py @@ -5,7 +5,6 @@ import ddt from django.core.management import CommandError, call_command from django.test import TestCase -from contentstore.management.commands.create_course import Command from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.django import modulestore @@ -18,26 +17,24 @@ class TestArgParsing(TestCase): def setUp(self): super(TestArgParsing, self).setUp() - self.command = Command() - def test_no_args(self): - errstring = "create_course requires 5 arguments" + errstring = "Error: too few arguments" with self.assertRaisesRegexp(CommandError, errstring): - self.command.handle('create_course') + call_command('create_course') def test_invalid_store(self): with self.assertRaises(CommandError): - self.command.handle("foo", "user@foo.org", "org", "course", "run") + call_command('create_course', "foo", "user@foo.org", "org", "course", "run") def test_nonexistent_user_id(self): errstring = "No user 99 found" with self.assertRaisesRegexp(CommandError, errstring): - self.command.handle("split", "99", "org", "course", "run") + call_command('create_course', "split", "99", "org", "course", "run") def test_nonexistent_user_email(self): errstring = "No user fake@example.com found" with self.assertRaisesRegexp(CommandError, errstring): - self.command.handle("mongo", "fake@example.com", "org", "course", "run") + call_command('create_course', "mongo", "fake@example.com", "org", "course", "run") @ddt.ddt diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py index 14c36448c2..9b2583b85f 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_git_export.py @@ -56,10 +56,10 @@ class TestGitExport(CourseTestCase): Test that the command interface works. Ignore stderr for clean test output. """ - with self.assertRaisesRegexp(CommandError, 'This script requires.*'): + with self.assertRaisesRegexp(CommandError, 'Error: unrecognized arguments:*'): call_command('git_export', 'blah', 'blah', 'blah', stderr=StringIO.StringIO()) - with self.assertRaisesRegexp(CommandError, 'This script requires.*'): + with self.assertRaisesMessage(CommandError, 'Error: too few arguments'): call_command('git_export', stderr=StringIO.StringIO()) # Send bad url to get course not exported diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_migrate_to_split.py b/cms/djangoapps/contentstore/management/commands/tests/test_migrate_to_split.py index 1af93bed15..2d68db80fb 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_migrate_to_split.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_migrate_to_split.py @@ -3,7 +3,6 @@ Unittests for migrating a course to split mongo """ from django.core.management import CommandError, call_command from django.test import TestCase -from contentstore.management.commands.migrate_to_split import Command from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -17,15 +16,14 @@ class TestArgParsing(TestCase): """ def setUp(self): super(TestArgParsing, self).setUp() - self.command = Command() def test_no_args(self): """ Test the arg length error """ - errstring = "migrate_to_split requires at least two arguments" + errstring = "Error: too few arguments" with self.assertRaisesRegexp(CommandError, errstring): - self.command.handle() + call_command("migrate_to_split") def test_invalid_location(self): """ @@ -33,7 +31,7 @@ class TestArgParsing(TestCase): """ errstring = "Invalid location string" with self.assertRaisesRegexp(CommandError, errstring): - self.command.handle("foo", "bar") + call_command("migrate_to_split", "foo", "bar") def test_nonexistent_user_id(self): """ @@ -41,7 +39,7 @@ class TestArgParsing(TestCase): """ errstring = "No user found identified by 99" with self.assertRaisesRegexp(CommandError, errstring): - self.command.handle("org/course/name", "99") + call_command("migrate_to_split", "org/course/name", "99") def test_nonexistent_user_email(self): """ @@ -49,7 +47,7 @@ class TestArgParsing(TestCase): """ errstring = "No user found identified by fake@example.com" with self.assertRaisesRegexp(CommandError, errstring): - self.command.handle("org/course/name", "fake@example.com") + call_command("migrate_to_split", "org/course/name", "fake@example.com") # pylint: disable=no-member, protected-access @@ -77,13 +75,6 @@ class TestMigrateToSplit(ModuleStoreTestCase): split_store.has_course(new_key), "Could not find course" ) - # I put this in but realized that the migrator doesn't make the new course the - # default mapping in mixed modulestore. I left the test here so we can debate what it ought to do. -# self.assertEqual( -# ModuleStoreEnum.Type.split, -# modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type(), -# "Split is not the new default for the course" -# ) def test_user_id(self): """ @@ -104,7 +95,9 @@ class TestMigrateToSplit(ModuleStoreTestCase): "migrate_to_split", str(self.course.id), str(self.user.id), - "org.dept", "name", "run", + org="org.dept", + course="name", + run="run", ) split_store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.split) locator = split_store.make_course_key("org.dept", "name", "run") diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py index 3d3d99b91a..6603c0c399 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_courses.py @@ -48,7 +48,7 @@ class TestReindexCourse(ModuleStoreTestCase): def test_given_no_arguments_raises_command_error(self): """ Test that raises CommandError for incorrect arguments """ - with self.assertRaisesRegexp(CommandError, ".* requires one or more arguments.*"): + with self.assertRaisesRegexp(CommandError, ".* requires one or more *"): call_command('reindex_course') @ddt.data('qwerty', 'invalid_key', 'xblockv1:qwerty') diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py index d44b8a3886..0bb7a58de6 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py @@ -49,7 +49,7 @@ class TestReindexLibrary(ModuleStoreTestCase): def test_given_no_arguments_raises_command_error(self): """ Test that raises CommandError for incorrect arguments """ - with self.assertRaisesRegexp(CommandError, ".* requires one or more arguments.*"): + with self.assertRaisesRegexp(CommandError, ".* requires one or more *"): call_command('reindex_library') @ddt.data('qwerty', 'invalid_key', 'xblock-v1:qwe+rty') From ce50c9e620405b5452220fa0965200ecab67a491 Mon Sep 17 00:00:00 2001 From: bmedx Date: Mon, 30 Oct 2017 14:21:06 -0400 Subject: [PATCH 25/47] Student management command cleanup for Django 1.11 --- .../management/commands/add_to_group.py | 46 ++++----- .../commands/anonymized_id_mapping.py | 14 +-- .../management/commands/assigngroups.py | 63 ++++++------ .../commands/bulk_change_enrollment.py | 67 +++++-------- .../management/commands/cert_restriction.py | 99 ++++++++----------- .../management/commands/change_enrollment.py | 84 ++++++++-------- .../commands/create_random_users.py | 23 ++--- .../management/commands/create_user.py | 86 +++++++--------- ...populate_created_on_site_user_attribute.py | 5 +- .../student/management/commands/set_staff.py | 56 +++++------ .../management/commands/set_superuser.py | 32 +++--- .../tests/test_bulk_change_enrollment.py | 62 +++++++----- .../tests/test_change_enrollment.py | 37 ++++--- 13 files changed, 317 insertions(+), 357 deletions(-) diff --git a/common/djangoapps/student/management/commands/add_to_group.py b/common/djangoapps/student/management/commands/add_to_group.py index 66f0f4eaca..28e5b3582b 100644 --- a/common/djangoapps/student/management/commands/add_to_group.py +++ b/common/djangoapps/student/management/commands/add_to_group.py @@ -1,45 +1,39 @@ -from optparse import make_option +from __future__ import print_function from django.core.management.base import BaseCommand, CommandError from django.contrib.auth.models import User, Group class Command(BaseCommand): - option_list = BaseCommand.option_list + ( - make_option('--list', - action='store_true', - dest='list', - default=False, - help='List available groups'), - make_option('--create', - action='store_true', - dest='create', - default=False, - help='Create the group if it does not exist'), - make_option('--remove', - action='store_true', - dest='remove', - default=False, - help='Remove the user from the group instead of adding it'), - ) + def add_arguments(self, parser): + parser.add_argument('name_or_email', + help='Username or email address of the user to add or remove') + parser.add_argument('group_name', + help='Name of the group to change') + parser.add_argument('--list', + action='store_true', + help='List available groups') + parser.add_argument('--create', + action='store_true', + help='Create the group if it does not exist') + parser.add_argument('--remove', + action='store_true', + help='Remove the user from the group instead of adding it') - args = ' ' help = 'Add a user to a group' def print_groups(self): - print 'Groups available:' + print('Groups available:') for group in Group.objects.all().distinct(): - print ' ', group.name + print(' {}'.format(group.name)) def handle(self, *args, **options): if options['list']: self.print_groups() return - if len(args) != 2: - raise CommandError('Usage is add_to_group {0}'.format(self.args)) - - name_or_email, group_name = args + name_or_email = options['name_or_email'] + group_name = options['group_name'] if '@' in name_or_email: user = User.objects.get(email=name_or_email) @@ -60,4 +54,4 @@ class Command(BaseCommand): else: user.groups.add(group) - print 'Success!' + print('Success!') diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index ce08b39446..6315facc65 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -20,23 +20,17 @@ from opaque_keys.edx.keys import CourseKey class Command(BaseCommand): """Add our handler to the space where django-admin looks up commands.""" - # TODO: revisit now that rake has been deprecated - # It appears that with the way Rake invokes these commands, we can't - # have more than one arg passed through...annoying. - args = ("course_id", ) - help = """Export a CSV mapping usernames to anonymized ids Exports a CSV document mapping each username in the specified course to the anonymized, unique user ID. """ - def handle(self, *args, **options): - if len(args) != 1: - raise CommandError("Usage: unique_id_mapping %s" % - " ".join(("<%s>" % arg for arg in Command.args))) + def add_arguments(self, parser): + parser.add_argument('course_id') - course_key = CourseKey.from_string(args[0]) + def handle(self, *args, **options): + course_key = CourseKey.from_string(options['course_id']) # Generate the output filename from the course ID. # Change slashes to dashes first, and then append .csv extension. diff --git a/common/djangoapps/student/management/commands/assigngroups.py b/common/djangoapps/student/management/commands/assigngroups.py index 166e5cab16..ed5bf08ef5 100644 --- a/common/djangoapps/student/management/commands/assigngroups.py +++ b/common/djangoapps/student/management/commands/assigngroups.py @@ -1,3 +1,5 @@ +from __future__ import print_function + from django.core.management.base import BaseCommand from django.contrib.auth.models import User @@ -11,22 +13,27 @@ from textwrap import dedent import json from pytz import UTC +# Examples: +# python manage.py assigngroups summary_test:0.3,skip_summary_test:0.7 log.txt "Do previews of future materials help?" +# python manage.py assigngroups skip_capacitor:0.3,capacitor:0.7 log.txt "Do we show capacitor in linearity tutorial?" + def group_from_value(groups, v): - ''' Given group: (('a',0.3),('b',0.4),('c',0.3)) And random value + """ + Given group: (('a',0.3),('b',0.4),('c',0.3)) And random value in [0,1], return the associated group (in the above case, return 'a' if v<0.3, 'b' if 0.3<=v<0.7, and 'c' if v>0.7 -''' - sum = 0 - for (g, p) in groups: - sum = sum + p - if sum > v: - return g - return g # For round-off errors + """ + curr_sum = 0 + for (group, p_value) in groups: + curr_sum = curr_sum + p_value + if curr_sum > v: + return group + return group # For round-off errors class Command(BaseCommand): - help = dedent("""\ + help = dedent(""" Assign users to test groups. Takes a list of groups: a:0.3,b:0.4,c:0.3 file.txt "Testing something" Will assign each user to group a, b, or c with @@ -36,48 +43,49 @@ class Command(BaseCommand): Will log what happened to file.txt. """) + def add_arguments(self, parser): + parser.add_argument('group_and_score') + parser.add_argument('log_name') + parser.add_argument('description') + def handle(self, *args, **options): - if len(args) != 3: - print "Invalid number of options" - sys.exit(-1) - # Extract groups from string - group_strs = [x.split(':') for x in args[0].split(',')] + group_strs = [x.split(':') for x in options['group_and_score'].split(',')] groups = [(group, float(value)) for group, value in group_strs] - print "Groups", groups + print("Groups", groups) - ## Confirm group probabilities add up to 1 + # Confirm group probabilities add up to 1 total = sum(zip(*groups)[1]) - print "Total:", total + print("Total:", total) if abs(total - 1) > 0.01: - print "Total not 1" + print("Total not 1") sys.exit(-1) - ## Confirm groups don't already exist + # Confirm groups don't already exist for group in dict(groups): if UserTestGroup.objects.filter(name=group).count() != 0: - print group, "already exists!" + print(group, "already exists!") sys.exit(-1) group_objects = {} - f = open(args[1], "a+") + f = open(options['log_name'], "a+") - ## Create groups + # Create groups for group in dict(groups): utg = UserTestGroup() utg.name = group - utg.description = json.dumps({"description": args[2]}, + utg.description = json.dumps({"description": options['description']}, {"time": datetime.datetime.now(UTC).isoformat()}) group_objects[group] = utg group_objects[group].save() - ## Assign groups + # Assign groups users = list(User.objects.all()) count = 0 for user in users: if count % 1000 == 0: - print count + print(count) count = count + 1 v = random.uniform(0, 1) group = group_from_value(groups, v) @@ -88,10 +96,7 @@ class Command(BaseCommand): group=group ).encode('utf-8')) - ## Save groups + # Save groups for group in group_objects: group_objects[group].save() f.close() - -# python manage.py assigngroups summary_test:0.3,skip_summary_test:0.7 log.txt "Do previews of future materials help?" -# python manage.py assigngroups skip_capacitor:0.3,capacitor:0.7 log.txt "Do we show capacitor in linearity tutorial?" diff --git a/common/djangoapps/student/management/commands/bulk_change_enrollment.py b/common/djangoapps/student/management/commands/bulk_change_enrollment.py index 569dc78488..9dbda939c0 100644 --- a/common/djangoapps/student/management/commands/bulk_change_enrollment.py +++ b/common/djangoapps/student/management/commands/bulk_change_enrollment.py @@ -6,6 +6,7 @@ from django.db import transaction from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from optparse import make_option +from six import text_type from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from course_modes.models import CourseMode @@ -31,54 +32,36 @@ class Command(BaseCommand): Without the --commit option, the command will have no effect. """ - option_list = BaseCommand.option_list + ( - make_option( - '-f', '--from_mode', - dest='from_mode', - default=None, - help='move from this enrollment mode' - ), - make_option( - '-t', '--to_mode', - dest='to_mode', - default=None, - help='move to this enrollment mode' - ), - make_option( + def add_arguments(self, parser): + group = parser.add_mutually_exclusive_group() + group.add_argument( '-c', '--course', - dest='course', - default=None, - help='the course to change enrollments in' - ), - make_option( + help='The course to change enrollments in') + group.add_argument( '-o', '--org', - dest='org', - default=None, - help='all courses belonging to this org will be selected for changing the enrollments' - ), - make_option( + help='All courses belonging to this org will be selected for changing the enrollments') + + parser.add_argument( + '-f', '--from_mode', + required=True, + help='Move from this enrollment mode') + parser.add_argument( + '-t', '--to_mode', + required=True, + help='Move to this enrollment mode') + parser.add_argument( '--commit', action='store_true', - dest='commit', - default=False, - help='display what will be done without any effect' - ) - ) + help='Save the changes, without this flag only a dry run will be performed and nothing will be changed') def handle(self, *args, **options): - course_id = options.get('course') - org = options.get('org') - from_mode = options.get('from_mode') - to_mode = options.get('to_mode') - commit = options.get('commit') - - if (not course_id and not org) or (course_id and org): - raise CommandError('You must provide either a course ID or an org, but not both.') - - if from_mode is None or to_mode is None: - raise CommandError('Both `from` and `to` course modes must be given.') - + course_id = options['course'] + org = options['org'] + from_mode = options['from_mode'] + to_mode = options['to_mode'] + commit = options['commit'] course_keys = [] + if course_id: try: course_key = CourseKey.from_string(course_id) @@ -111,7 +94,7 @@ class Command(BaseCommand): commit (bool): required to make the change to the database. Otherwise just a count will be displayed. """ - unicode_course_key = unicode(course_key) + unicode_course_key = text_type(course_key) if CourseMode.mode_for_course(course_key, to_mode) is None: logger.info('Mode ({}) does not exist for course ({}).'.format(to_mode, unicode_course_key)) return diff --git a/common/djangoapps/student/management/commands/cert_restriction.py b/common/djangoapps/student/management/commands/cert_restriction.py index c43ff05f3e..f57ccf70d5 100644 --- a/common/djangoapps/student/management/commands/cert_restriction.py +++ b/common/djangoapps/student/management/commands/cert_restriction.py @@ -1,12 +1,14 @@ -from django.core.management.base import BaseCommand, CommandError -import os -from optparse import make_option -from student.models import UserProfile +from __future__ import print_function + import csv +import os + +from django.core.management.base import BaseCommand, CommandError + +from student.models import UserProfile class Command(BaseCommand): - help = """ Sets or gets certificate restrictions for users from embargoed countries. (allow_certificate in @@ -31,79 +33,62 @@ class Command(BaseCommand): """ - option_list = BaseCommand.option_list + ( - make_option('-i', '--import', - metavar='IMPORT_FILE', - dest='import', - default=False, - help='csv file to import, comma delimitted file with ' - 'double-quoted entries'), - make_option('-o', '--output', - metavar='EXPORT_FILE', - dest='output', - default=False, - help='csv file to export'), - make_option('-e', '--enable', - metavar='STUDENT', - dest='enable', - default=False, - help="enable a single student's certificate"), - make_option('-d', '--disable', - metavar='STUDENT', - dest='disable', - default=False, - help="disable a single student's certificate") - ) + def add_arguments(self, parser): + # This command can only take one of these arguments per run, this enforces that. + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('-i', '--import', + metavar='IMPORT_FILE', + nargs='?', + help='CSV file to import, comma delimitted file with double-quoted entries') + group.add_argument('-o', '--output', + metavar='EXPORT_FILE', + nargs='?', + help='CSV file to export') + group.add_argument('-e', '--enable', + metavar='STUDENT', + nargs='?', + help='Enable a certificate for a single student') + group.add_argument('-d', '--disable', + metavar='STUDENT', + nargs='?', + help='Disable a certificate for a single student') def handle(self, *args, **options): if options['output']: - if os.path.exists(options['output']): - raise CommandError("File {0} already exists".format( - options['output'])) - disabled_users = UserProfile.objects.filter( - allow_certificate=False) + raise CommandError("File {0} already exists".format(options['output'])) + disabled_users = UserProfile.objects.filter(allow_certificate=False) with open(options['output'], 'w') as csvfile: - csvwriter = csv.writer(csvfile, delimiter=',', quotechar='"', - quoting=csv.QUOTE_MINIMAL) + csvwriter = csv.writer(csvfile, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) for user in disabled_users: csvwriter.writerow([user.user.username]) + print('{} disabled users written'.format(len(disabled_users))) elif options['import']: - if not os.path.exists(options['import']): - raise CommandError("File {0} does not exist".format( - options['import'])) + raise CommandError("File {0} does not exist".format(options['import'])) - print "Importing students from {0}".format(options['import']) + print("Importing students from {0}".format(options['import'])) - students = None with open(options['import']) as csvfile: - student_list = csv.reader(csvfile, delimiter=',', - quotechar='"') + student_list = csv.reader(csvfile, delimiter=',', quotechar='"') students = [student[0] for student in student_list] + if not students: - raise CommandError( - "Unable to read student data from {0}".format( - options['import'])) - UserProfile.objects.filter(user__username__in=students).update( - allow_certificate=False) + raise CommandError("Unable to read student data from {0}".format(options['import'])) + + update_cnt = UserProfile.objects.filter(user__username__in=students).update(allow_certificate=False) + print('{} user(s) disabled out of {} in CSV file'.format(update_cnt, len(students))) elif options['enable']: - - print "Enabling {0} for certificate download".format( - options['enable']) - cert_allow = UserProfile.objects.get( - user__username=options['enable']) + print("Enabling {0} for certificate download".format(options['enable'])) + cert_allow = UserProfile.objects.get(user__username=options['enable']) cert_allow.allow_certificate = True cert_allow.save() elif options['disable']: - - print "Disabling {0} for certificate download".format( - options['disable']) - cert_allow = UserProfile.objects.get( - user__username=options['disable']) + print("Disabling {0} for certificate download".format(options['disable'])) + cert_allow = UserProfile.objects.get(user__username=options['disable']) cert_allow.allow_certificate = False cert_allow.save() diff --git a/common/djangoapps/student/management/commands/change_enrollment.py b/common/djangoapps/student/management/commands/change_enrollment.py index 38c12d8d0d..efd0e51b6c 100644 --- a/common/djangoapps/student/management/commands/change_enrollment.py +++ b/common/djangoapps/student/management/commands/change_enrollment.py @@ -4,8 +4,8 @@ import logging from django.core.management.base import BaseCommand, CommandError from django.db import transaction +from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from optparse import make_option from student.models import CourseEnrollment, User @@ -33,64 +33,60 @@ class Command(BaseCommand): Or - $ ... change_enrollment -e "joe@example.com,frank@example.com,bill@example.com" -c some/course/id --from audit --to honor + $ ... change_enrollment -e "joe@example.com,frank@example.com,..." -c some/course/id --from audit --to honor See what would have been changed from audit to honor without making that change $ ... change_enrollment -u joe,frank,bill -c some/course/id --from audit --to honor -n - """ - option_list = BaseCommand.option_list + ( - make_option('-f', '--from', - metavar='FROM_MODE', - dest='from_mode', - default=False, - help='move from this enrollment mode'), - make_option('-t', '--to', - metavar='TO_MODE', - dest='to_mode', - default=False, - help='move to this enrollment mode'), - make_option('-u', '--usernames', - metavar='USERNAME', - dest='username', - default=False, - help="Comma-separated list of usernames to move in the course"), - make_option('-e', '--emails', - metavar='EMAIL', - dest='email', - default=False, - help="Comma-separated list of email addresses to move in the course"), - make_option('-c', '--course', - metavar='COURSE_ID', - dest='course_id', - default=False, - help="course id to use for transfer"), - make_option('-n', '--noop', - action='store_true', - dest='noop', - default=False, - help="display what will be done but don't actually do anything") + enrollment_modes = ('audit', 'verified', 'honor') - ) + def add_arguments(self, parser): + parser.add_argument('-f', '--from', + metavar='FROM_MODE', + dest='from_mode', + required=True, + choices=self.enrollment_modes, + help='Move from this enrollment mode') + parser.add_argument('-t', '--to', + metavar='TO_MODE', + dest='to_mode', + required=True, + choices=self.enrollment_modes, + help='Move to this enrollment mode') + parser.add_argument('-u', '--username', + metavar='USERNAME', + help='Comma-separated list of usernames to move in the course') + parser.add_argument('-e', '--email', + metavar='EMAIL', + help='Comma-separated list of email addresses to move in the course') + parser.add_argument('-c', '--course', + metavar='COURSE_ID', + dest='course_id', + required=True, + help='Course id to use for transfer') + parser.add_argument('-n', '--noop', + action='store_true', + help='Display what will be done but do not actually do anything') def handle(self, *args, **options): - error_users = [] - success_users = [] + try: + course_key = CourseKey.from_string(options['course_id']) + except InvalidKeyError: + raise CommandError('Invalid or non-existant course id {}'.format(options['course_id'])) - if not options['course_id']: - raise CommandError('You must specify a course id for this command') - if not options['from_mode'] or not options['to_mode']: - raise CommandError('You must specify a "to" and "from" mode as parameters') - - course_key = CourseKey.from_string(options['course_id']) + if not options['username'] and not options['email']: + raise CommandError('You must include usernames (-u) or emails (-e) to select users to update') enrollment_args = dict( course_id=course_key, mode=options['from_mode'] ) + error_users = [] + success_users = [] + if options['username']: self.update_enrollments('username', enrollment_args, options, error_users, success_users) @@ -102,8 +98,10 @@ class Command(BaseCommand): def update_enrollments(self, identifier, enrollment_args, options, error_users, success_users): """ Update enrollments for a specific user identifier (email or username). """ users = options[identifier].split(",") + for identified_user in users: logger.info(identified_user) + try: user_args = { identifier: identified_user diff --git a/common/djangoapps/student/management/commands/create_random_users.py b/common/djangoapps/student/management/commands/create_random_users.py index 7c58f1eb71..79de0a585a 100644 --- a/common/djangoapps/student/management/commands/create_random_users.py +++ b/common/djangoapps/student/management/commands/create_random_users.py @@ -1,6 +1,7 @@ """ A script to create some dummy users """ +from __future__ import print_function import uuid from django.core.management.base import BaseCommand @@ -32,6 +33,7 @@ def create(num, course_key): (user, _, _) = _do_create_account(make_random_form()) if course_key is not None: CourseEnrollment.enroll(user, course_key) + print('Created user {}'.format(user.username)) class Command(BaseCommand): @@ -45,16 +47,15 @@ Examples: create_random_users.py 100 HarvardX/CS50x/2012 """ + def add_arguments(self, parser): + parser.add_argument('num_users', + help='Number of users to create', + type=int) + parser.add_argument('course_key', + help='Add newly created users to this course', + nargs='?') + def handle(self, *args, **options): - if len(args) < 1 or len(args) > 2: - print Command.help - return - - num = int(args[0]) - - if len(args) == 2: - course_key = CourseKey.from_string(args[1]) - else: - course_key = None - + num = options['num_users'] + course_key = CourseKey.from_string(options['course_key']) if options['course_key'] else None create(num, course_key) diff --git a/common/djangoapps/student/management/commands/create_user.py b/common/djangoapps/student/management/commands/create_user.py index c98452ae49..51ecb12043 100644 --- a/common/djangoapps/student/management/commands/create_user.py +++ b/common/djangoapps/student/management/commands/create_user.py @@ -1,8 +1,7 @@ -from optparse import make_option +from __future__ import print_function from django.conf import settings from django.contrib.auth.models import User -from django.core.management.base import BaseCommand from django.utils import translation from opaque_keys.edx.keys import CourseKey @@ -23,56 +22,39 @@ class Command(TrackedCommand): manage.py ... create_user -e test@example.com -p insecure -c edX/Open_DemoX/edx_demo_course -m verified """ - option_list = BaseCommand.option_list + ( - make_option('-m', '--mode', - metavar='ENROLLMENT_MODE', - dest='mode', - default='honor', - choices=('audit', 'verified', 'honor'), - help='Enrollment type for user for a specific course'), - make_option('-u', '--username', - metavar='USERNAME', - dest='username', - default=None, - help='Username, defaults to "user" in the email'), - make_option('-n', '--name', - metavar='NAME', - dest='name', - default=None, - help='Name, defaults to "user" in the email'), - make_option('-p', '--password', - metavar='PASSWORD', - dest='password', - default=None, - help='Password for user'), - make_option('-e', '--email', - metavar='EMAIL', - dest='email', - default=None, - help='Email for user'), - make_option('-c', '--course', - metavar='COURSE_ID', - dest='course', - default=None, - help='course to enroll the user in (optional)'), - make_option('-s', '--staff', - dest='staff', - default=False, - action='store_true', - help='give user the staff bit'), - ) + def add_arguments(self, parser): + parser.add_argument('-m', '--mode', + metavar='ENROLLMENT_MODE', + default='honor', + choices=('audit', 'verified', 'honor'), + help='Enrollment type for user for a specific course, defaults to "honor"') + parser.add_argument('-u', '--username', + metavar='USERNAME', + help='Username, defaults to "user" in the email') + parser.add_argument('-n', '--name', + metavar='NAME', + help='Name, defaults to "user" in the email') + parser.add_argument('-p', '--password', + metavar='PASSWORD', + help='Password for user', + required=True) + parser.add_argument('-e', '--email', + metavar='EMAIL', + help='Email for user', + required=True) + parser.add_argument('-c', '--course', + metavar='COURSE_ID', + help='Course to enroll the user in (optional)') + parser.add_argument('-s', '--staff', + action='store_true', + help='Give user the staff bit, defaults to off') def handle(self, *args, **options): - username = options['username'] - name = options['name'] - if not username: - username = options['email'].split('@')[0] - if not name: - name = options['email'].split('@')[0] + username = options['username'] if options['username'] else options['email'].split('@')[0] + name = options['name'] if options['name'] else options['email'].split('@')[0] # parse out the course into a coursekey - if options['course']: - course = CourseKey.from_string(options['course']) + course = CourseKey.from_string(options['course']) if options['course'] else None form = AccountCreationForm( data={ @@ -83,11 +65,13 @@ class Command(TrackedCommand): }, tos_required=False ) + # django.utils.translation.get_language() will be used to set the new # user's preferred language. This line ensures that the result will # match this installation's default locale. Otherwise, inside a # management command, it will always return "en-us". translation.activate(settings.LANGUAGE_CODE) + try: user, _, reg = _do_create_account(form) if options['staff']: @@ -97,8 +81,10 @@ class Command(TrackedCommand): reg.save() create_comments_service_user(user) except AccountValidationError as e: - print e.message + print(e.message) user = User.objects.get(email=options['email']) - if options['course']: + + if course: CourseEnrollment.enroll(user, course, mode=options['mode']) + translation.deactivate() diff --git a/common/djangoapps/student/management/commands/populate_created_on_site_user_attribute.py b/common/djangoapps/student/management/commands/populate_created_on_site_user_attribute.py index 34a495e16d..78819726ae 100644 --- a/common/djangoapps/student/management/commands/populate_created_on_site_user_attribute.py +++ b/common/djangoapps/student/management/commands/populate_created_on_site_user_attribute.py @@ -14,7 +14,7 @@ class Command(BaseCommand): This command back-populates domain of the site the user account was created on. """ help = """./manage.py lms populate_created_on_site_user_attribute --users ,... - '--activation-keys ,... --site-domain --settings=devstack""" + '--activation-keys ,... --site-domain --settings=devstack_docker""" def add_arguments(self, parser): """ @@ -35,6 +35,7 @@ class Command(BaseCommand): parser.add_argument( '--site-domain', help='Enter an existing site domain.', + required=True ) def handle(self, *args, **options): @@ -42,8 +43,6 @@ class Command(BaseCommand): user_ids = options['users'].split(',') if options['users'] else [] activation_keys = options['activation_keys'].split(',') if options['activation_keys'] else [] - if not site_domain: - raise CommandError('You must provide site-domain argument.') if not user_ids and not activation_keys: raise CommandError('You must provide user ids or activation keys.') diff --git a/common/djangoapps/student/management/commands/set_staff.py b/common/djangoapps/student/management/commands/set_staff.py index 1556923253..cf8134c36a 100644 --- a/common/djangoapps/student/management/commands/set_staff.py +++ b/common/djangoapps/student/management/commands/set_staff.py @@ -1,47 +1,45 @@ -from optparse import make_option +from __future__ import print_function +import re from django.contrib.auth.models import User -from django.core.management.base import BaseCommand, CommandError -import re +from django.core.management.base import BaseCommand class Command(BaseCommand): - option_list = BaseCommand.option_list + ( - make_option('--unset', - action='store_true', - dest='unset', - default=False, - help='Set is_staff to False instead of True'), - ) - args = ' [user|email ...]>' help = """ This command will set is_staff to true for one or more users. Lookup by username or email address, assumes usernames do not look like email addresses. """ + def add_arguments(self, parser): + parser.add_argument('users', + nargs='+', + help='Users to set or unset (with the --unset flag) as superusers') + parser.add_argument('--unset', + action='store_true', + dest='unset', + default=False, + help='Set is_staff to False instead of True') + def handle(self, *args, **options): - if len(args) < 1: - raise CommandError('Usage is set_staff {0}'.format(self.args)) - - for user in args: - if re.match(r'[^@]+@[^@]+\.[^@]+', user): - try: + for user in options['users']: + try: + if re.match(r'[^@]+@[^@]+\.[^@]+', user): v = User.objects.get(email=user) - except: - raise CommandError("User {0} does not exist".format(user)) - else: - try: + else: v = User.objects.get(username=user) - except: - raise CommandError("User {0} does not exist".format(user)) - if options['unset']: - v.is_staff = False - else: - v.is_staff = True + if options['unset']: + v.is_staff = False + else: + v.is_staff = True - v.save() + v.save() + print('Modified {} sucessfully.'.format(user)) - print 'Success!' + except Exception as err: # pylint: disable=broad-except + print("Error modifying user with identifier {}: {}: {}".format(user, type(err).__name__, err.message)) + + print('Complete!') diff --git a/common/djangoapps/student/management/commands/set_superuser.py b/common/djangoapps/student/management/commands/set_superuser.py index 068742bc8c..ee65cbd09f 100644 --- a/common/djangoapps/student/management/commands/set_superuser.py +++ b/common/djangoapps/student/management/commands/set_superuser.py @@ -1,32 +1,31 @@ """Management command to grant or revoke superuser access for one or more users""" +from __future__ import print_function -from optparse import make_option from django.contrib.auth.models import User -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand class Command(BaseCommand): """Management command to grant or revoke superuser access for one or more users""" - option_list = BaseCommand.option_list + ( - make_option('--unset', - action='store_true', - dest='unset', - default=False, - help='Set is_superuser to False instead of True'), - ) - args = ' [user|email ...]>' help = """ This command will set is_superuser to true for one or more users. Lookup by username or email address, assumes usernames do not look like email addresses. """ - def handle(self, *args, **options): - if len(args) < 1: - raise CommandError('Usage is set_superuser {0}'.format(self.args)) + def add_arguments(self, parser): + parser.add_argument('users', + nargs='+', + help='Users to set or unset (with the --unset flag) as superusers') + parser.add_argument('--unset', + action='store_true', + dest='unset', + default=False, + help='Set is_superuser to False instead of True') - for user in args: + def handle(self, *args, **options): + for user in options['users']: try: if '@' in user: userobj = User.objects.get(email=user) @@ -39,8 +38,9 @@ class Command(BaseCommand): userobj.is_superuser = True userobj.save() + print('Modified {} sucessfully.'.format(user)) except Exception as err: # pylint: disable=broad-except - print "Error modifying user with identifier {}: {}: {}".format(user, type(err).__name__, err.message) + print("Error modifying user with identifier {}: {}: {}".format(user, type(err).__name__, err.message)) - print 'Success!' + print('Complete!') diff --git a/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py b/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py index 00c903631d..7fb1076908 100644 --- a/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py +++ b/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py @@ -1,5 +1,7 @@ """Tests for the bulk_change_enrollment command.""" import ddt +from six import text_type + from django.core.management import call_command from django.core.management.base import CommandError from mock import patch, call @@ -35,12 +37,15 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): # Verify that no users are in the `from` mode yet. self.assertEqual(len(CourseEnrollment.objects.filter(mode=to_mode, course_id=self.course.id)), 0) + args = '--course {course} --from_mode {from_mode} --to_mode {to_mode} --commit'.format( + course=text_type(self.course.id), + from_mode=from_mode, + to_mode=to_mode + ) + call_command( 'bulk_change_enrollment', - course=unicode(self.course.id), - from_mode=from_mode, - to_mode=to_mode, - commit=True, + *args.split(' ') ) # Verify that all users have been moved -- if not, this will @@ -67,12 +72,15 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): self.assertEqual(len(CourseEnrollment.objects.filter(mode=to_mode, course_id=self.course.id)), 0) self.assertEqual(len(CourseEnrollment.objects.filter(mode=to_mode, course_id=course_2.id)), 0) - call_command( - 'bulk_change_enrollment', + args = '--org {org} --from_mode {from_mode} --to_mode {to_mode} --commit'.format( org=self.org, from_mode=from_mode, - to_mode=to_mode, - commit=True, + to_mode=to_mode + ) + + call_command( + 'bulk_change_enrollment', + *args.split(' ') ) # Verify that all users have been moved -- if not, this will @@ -91,7 +99,7 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): call_command( 'bulk_change_enrollment', org=self.org, - course=unicode(self.course.id), + course=text_type(self.course.id), from_mode='audit', to_mode='no-id-professional', commit=True, @@ -114,12 +122,15 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): self.assertEqual(len(CourseEnrollment.objects.filter(mode=to_mode, course_id=self.course.id)), 0) self.assertEqual(len(CourseEnrollment.objects.filter(mode=to_mode, course_id=course_2.id)), 0) - call_command( - 'bulk_change_enrollment', + args = '--org {org} --from_mode {from_mode} --to_mode {to_mode} --commit'.format( org=self.org, from_mode=from_mode, - to_mode=to_mode, - commit=True, + to_mode=to_mode + ) + + call_command( + 'bulk_change_enrollment', + *args.split(' ') ) # Verify that users were not moved for the invalid course/mode combination @@ -139,12 +150,15 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): CourseModeFactory(course_id=self.course.id, mode_slug='no-id-professional') with self.assertRaises(CommandError): - call_command( - 'bulk_change_enrollment', + args = '--org {org} --from_mode {from_mode} --to_mode {to_mode} --commit'.format( org='fakeX', from_mode='audit', to_mode='no-id-professional', - commit=True, + ) + + call_command( + 'bulk_change_enrollment', + *args.split(' ') ) def test_without_commit(self): @@ -152,11 +166,15 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): self._enroll_users(self.course, self.users, 'audit') CourseModeFactory(course_id=self.course.id, mode_slug='honor') + args = '--course {course} --from_mode {from_mode} --to_mode {to_mode}'.format( + course=text_type(self.course.id), + from_mode='audit', + to_mode='honor' + ) + call_command( 'bulk_change_enrollment', - course=unicode(self.course.id), - from_mode='audit', - to_mode='honor', + *args.split(' ') ) # Verify that no users are in the honor mode. @@ -170,7 +188,7 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): with self.assertRaises(CommandError): call_command( 'bulk_change_enrollment', - course=unicode(self.course.id), + course=text_type(self.course.id), from_mode='audit', ) @@ -180,7 +198,7 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): command_options = { 'from_mode': 'audit', 'to_mode': 'honor', - 'course': unicode(self.course.id), + 'course': text_type(self.course.id), } command_options.pop(option) @@ -209,7 +227,7 @@ class BulkChangeEnrollmentTests(SharedModuleStoreTestCase): [ call( EVENT_NAME_ENROLLMENT_MODE_CHANGED, - {'course_id': unicode(course.id), 'user_id': user.id, 'mode': to_mode} + {'course_id': text_type(course.id), 'user_id': user.id, 'mode': to_mode} ), ] ) diff --git a/common/djangoapps/student/management/tests/test_change_enrollment.py b/common/djangoapps/student/management/tests/test_change_enrollment.py index 5cfb8d9592..503f8a3d65 100644 --- a/common/djangoapps/student/management/tests/test_change_enrollment.py +++ b/common/djangoapps/student/management/tests/test_change_enrollment.py @@ -2,6 +2,7 @@ import ddt from mock import patch +from six import text_type from django.core.management import call_command from xmodule.modulestore.tests.factories import CourseFactory @@ -53,13 +54,6 @@ class ChangeEnrollmentTests(SharedModuleStoreTestCase): """ The command should update the user's enrollment. """ user_str = ','.join([getattr(user, method) for user in self.users]) user_ids = [u.id for u in self.users] - command_args = { - 'course_id': unicode(self.course.id), - 'to_mode': 'honor', - 'from_mode': 'audit', - 'noop': noop, - method: user_str, - } # Verify users are not in honor mode yet self.assertEqual( @@ -67,11 +61,19 @@ class ChangeEnrollmentTests(SharedModuleStoreTestCase): 0 ) - call_command( - 'change_enrollment', - **command_args + noop = " --noop" if noop else "" + + # Hack around call_command bugs dealing with required options see: + # https://stackoverflow.com/questions/32036562/call-command-argument-is-required + command_args = '--course {course} --to honor --from audit --{method} {user_str}{noop}'.format( + course=text_type(self.course.id), + noop=noop, + method=method, + user_str=user_str ) + call_command('change_enrollment', *command_args.split(' ')) + # Verify correct number of users are now in honor mode self.assertEqual( len(CourseEnrollment.objects.filter(mode='honor', user_id__in=user_ids)), @@ -95,12 +97,6 @@ class ChangeEnrollmentTests(SharedModuleStoreTestCase): all_users.append(fake_user) user_str = ','.join(all_users) real_user_ids = [u.id for u in self.users] - command_args = { - 'course_id': unicode(self.course.id), - 'to_mode': 'honor', - 'from_mode': 'audit', - method: user_str, - } # Verify users are not in honor mode yet self.assertEqual( @@ -108,11 +104,14 @@ class ChangeEnrollmentTests(SharedModuleStoreTestCase): 0 ) - call_command( - 'change_enrollment', - **command_args + command_args = '--course {course} --to honor --from audit --{method} {user_str}'.format( + course=text_type(self.course.id), + method=method, + user_str=user_str ) + call_command('change_enrollment', *command_args.split(' ')) + # Verify correct number of users are now in honor mode self.assertEqual( len(CourseEnrollment.objects.filter(mode='honor', user_id__in=real_user_ids)), From 2de2e3027d8db8c05049f34873f162bb86c59bda Mon Sep 17 00:00:00 2001 From: Jeremy Bowman Date: Tue, 31 Oct 2017 18:19:06 -0400 Subject: [PATCH 26/47] PLAT-1773 Delegate edx-proctoring service registration to app ready methods --- cms/envs/common.py | 2 +- lms/djangoapps/grades/apps.py | 5 +++++ lms/djangoapps/instructor/apps.py | 19 +++++++++++++++++++ lms/envs/common.py | 4 ++-- lms/startup.py | 17 ----------------- openedx/core/djangoapps/credit/apps.py | 19 +++++++++++++++++++ 6 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 lms/djangoapps/instructor/apps.py create mode 100644 openedx/core/djangoapps/credit/apps.py diff --git a/cms/envs/common.py b/cms/envs/common.py index a0d36d2f8f..07c31719ed 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1028,7 +1028,7 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.coursegraph.apps.CoursegraphConfig', # Credit courses - 'openedx.core.djangoapps.credit', + 'openedx.core.djangoapps.credit.apps.CreditConfig', 'xblock_django', diff --git a/lms/djangoapps/grades/apps.py b/lms/djangoapps/grades/apps.py index 3f509a9259..66684d5051 100644 --- a/lms/djangoapps/grades/apps.py +++ b/lms/djangoapps/grades/apps.py @@ -5,6 +5,8 @@ Signal handlers are connected here. """ from django.apps import AppConfig +from django.conf import settings +from edx_proctoring.runtime import set_runtime_service class GradesConfig(AppConfig): @@ -20,3 +22,6 @@ class GradesConfig(AppConfig): # Can't import models at module level in AppConfigs, and models get # included from the signal handlers from .signals import handlers # pylint: disable=unused-variable + if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): + from .services import GradesService + set_runtime_service('grades', GradesService()) diff --git a/lms/djangoapps/instructor/apps.py b/lms/djangoapps/instructor/apps.py new file mode 100644 index 0000000000..53922d66a0 --- /dev/null +++ b/lms/djangoapps/instructor/apps.py @@ -0,0 +1,19 @@ +""" +Instructor Application Configuration +""" + +from django.apps import AppConfig +from django.conf import settings +from edx_proctoring.runtime import set_runtime_service + + +class InstructorConfig(AppConfig): + """ + Default configuration for the "lms.djangoapps.instructor" Django application. + """ + name = u'lms.djangoapps.instructor' + + def ready(self): + if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): + from .services import InstructorService + set_runtime_service('instructor', InstructorService()) diff --git a/lms/envs/common.py b/lms/envs/common.py index 1477a1b429..ae9079993d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2074,7 +2074,7 @@ INSTALLED_APPS = [ 'util', 'certificates.apps.CertificatesConfig', 'dashboard', - 'lms.djangoapps.instructor', + 'lms.djangoapps.instructor.apps.InstructorConfig', 'lms.djangoapps.instructor_task', 'openedx.core.djangoapps.course_groups', 'bulk_email', @@ -2228,7 +2228,7 @@ INSTALLED_APPS = [ 'commerce', # Credit courses - 'openedx.core.djangoapps.credit', + 'openedx.core.djangoapps.credit.apps.CreditConfig', # Course teams 'lms.djangoapps.teams', diff --git a/lms/startup.py b/lms/startup.py index 969093f34a..83055939c9 100644 --- a/lms/startup.py +++ b/lms/startup.py @@ -49,23 +49,6 @@ def run(): # Mako requires the directories to be added after the django setup. microsite.enable_microsites(log) - # register any dependency injections that we need to support in edx_proctoring - # right now edx_proctoring is dependent on the openedx.core.djangoapps.credit and - # lms.djangoapps.grades - if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): - # Import these here to avoid circular dependencies of the form: - # edx-platform app --> DRF --> django translation --> edx-platform app - from edx_proctoring.runtime import set_runtime_service - from lms.djangoapps.instructor.services import InstructorService - from openedx.core.djangoapps.credit.services import CreditService - from lms.djangoapps.grades.services import GradesService - set_runtime_service('credit', CreditService()) - - # register InstructorService (for deleting student attempts and user staff access roles) - set_runtime_service('instructor', InstructorService()) - - set_runtime_service('grades', GradesService()) - # In order to allow modules to use a handler url, we need to # monkey-patch the x_module library. # TODO: Remove this code when Runtimes are no longer created by modulestores diff --git a/openedx/core/djangoapps/credit/apps.py b/openedx/core/djangoapps/credit/apps.py new file mode 100644 index 0000000000..eedac30579 --- /dev/null +++ b/openedx/core/djangoapps/credit/apps.py @@ -0,0 +1,19 @@ +""" +Credit Application Configuration +""" + +from django.apps import AppConfig +from django.conf import settings +from edx_proctoring.runtime import set_runtime_service + + +class CreditConfig(AppConfig): + """ + Default configuration for the "openedx.core.djangoapps.credit" Django application. + """ + name = u'openedx.core.djangoapps.credit' + + def ready(self): + if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): + from .services import CreditService + set_runtime_service('credit', CreditService()) From 83b38677940879866894898f8b8d963e074ebae8 Mon Sep 17 00:00:00 2001 From: Eric Fischer Date: Wed, 1 Nov 2017 10:39:44 -0400 Subject: [PATCH 27/47] Use style-loader --- webpack.dev.config.js | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 1301fe782a..6f52e8309d 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -30,23 +30,28 @@ module.exports = Merge.smart(commonConfig, { /paragon/, /font-awesome/ ], - use: [{ - loader: 'css-loader', - options: { - modules: true, - localIdentName: '[name]__[local]___[hash:base64:5]' + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + sourceMap: true, + modules: true, + localIdentName: '[path][name]__[local]--[hash:base64:5]' + } + }, + { + loader: 'sass-loader', + options: { + data: '$base-rem-size: 0.625; @import "paragon-reset";', + includePaths: [ + path.join(__dirname, './node_modules/@edx/paragon/src/utils'), + path.join(__dirname, './node_modules/') + ], + sourceMap: true + } } - }, { - loader: 'sass-loader', - options: { - data: '$base-rem-size: 0.625; @import "paragon-reset";', - includePaths: [ - path.join(__dirname, './node_modules/@edx/paragon/src/utils'), - path.join(__dirname, './node_modules/') - ], - sourceMap: true - } - }] + ] } ] } From 1b6ed3ba21ac373f66b5b64f96277ed7cc26494a Mon Sep 17 00:00:00 2001 From: uzairr Date: Fri, 13 Oct 2017 12:54:55 +0500 Subject: [PATCH 28/47] Celery task to update sailthru purchase record After change in the audit enrollment process, edX platform is no longer keeping its audit enrollment record on sailthru.To keep updated sailthru a celery task is created that will update sailthru user profile in case of enroll/un-enroll of any audit enrollment course. LEARNER-2694 --- lms/djangoapps/email_marketing/signals.py | 27 ++- lms/djangoapps/email_marketing/tasks.py | 188 ++++++++++++++++++ .../email_marketing/tests/test_signals.py | 114 ++++++++++- 3 files changed, 322 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/email_marketing/signals.py b/lms/djangoapps/email_marketing/signals.py index 8c2351deeb..af4d0ad7af 100644 --- a/lms/djangoapps/email_marketing/signals.py +++ b/lms/djangoapps/email_marketing/signals.py @@ -7,16 +7,19 @@ import logging import crum from django.conf import settings from django.dispatch import receiver -from sailthru.sailthru_client import SailthruClient from sailthru.sailthru_error import SailthruClientError from celery.exceptions import TimeoutError +from course_modes.models import CourseMode from email_marketing.models import EmailMarketingConfiguration +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace from lms.djangoapps.email_marketing.tasks import update_user, update_user_email, get_email_cookies_via_sailthru from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from student.cookies import CREATE_LOGON_COOKIE +from student.signals import ENROLL_STATUS_CHANGE from student.views import REGISTER_USER from util.model_utils import USER_FIELD_CHANGED +from .tasks import update_course_enrollment log = logging.getLogger(__name__) @@ -25,6 +28,28 @@ CHANGED_FIELDNAMES = ['username', 'is_active', 'name', 'gender', 'education', 'age', 'level_of_education', 'year_of_birth', 'country', LANGUAGE_KEY] +WAFFLE_NAMESPACE = 'sailthru' +WAFFLE_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE) + +SAILTHRU_AUDIT_PURCHASE_ENABLED = 'audit_purchase_enabled' + + +@receiver(ENROLL_STATUS_CHANGE) +def update_sailthru(sender, event, user, mode, course_id, **kwargs): + """ + Receives signal and calls a celery task to update the + enrollment track + Arguments: + user: current user + course_id: course key of a course + Returns: + None + """ + if WAFFLE_SWITCHES.is_enabled(SAILTHRU_AUDIT_PURCHASE_ENABLED) and mode in CourseMode.AUDIT_MODES: + course_key = str(course_id) + email = str(user.email) + update_course_enrollment.delay(email, course_key, mode) + @receiver(CREATE_LOGON_COOKIE) def add_email_marketing_cookies(sender, response=None, user=None, diff --git a/lms/djangoapps/email_marketing/tasks.py b/lms/djangoapps/email_marketing/tasks.py index a8e72ef521..2823335a66 100644 --- a/lms/djangoapps/email_marketing/tasks.py +++ b/lms/djangoapps/email_marketing/tasks.py @@ -291,3 +291,191 @@ def _retryable_sailthru_error(error): """ code = error.get_error_code() return code == 9 or code == 43 + + +@task(bind=True) +def update_course_enrollment(self, email, course_key, mode): + """Adds/updates Sailthru when a user adds to cart/purchases/upgrades a course + Args: + user: current user + course_key: course key of course + Returns: + None + """ + course_url = build_course_url(course_key) + config = EmailMarketingConfiguration.current() + + try: + sailthru_client = SailthruClient(config.sailthru_key, config.sailthru_secret) + except: + return + + send_template = config.sailthru_enroll_template + cost_in_cents = 0 + + if not update_unenrolled_list(sailthru_client, email, course_url, False): + schedule_retry(self, config) + + course_data = _get_course_content(course_key, course_url, sailthru_client, config) + + item = _build_purchase_item(course_key, course_url, cost_in_cents, mode, course_data, None) + options = {} + + if send_template: + options['send_template'] = send_template + + if not _record_purchase(sailthru_client, email, item, options): + schedule_retry(self, config) + + +def build_course_url(course_key): + """ + Generates and return url of the course info page by using course_key + Arguments: + course_key: course_key of the given course + Returns + a complete url of the course info page + """ + return '{base_url}/courses/{course_key}/info'.format(base_url=settings.LMS_ROOT_URL, + course_key=unicode(course_key)) + + +def update_unenrolled_list(sailthru_client, email, course_url, unenroll): + """Maintain a list of courses the user has unenrolled from in the Sailthru user record + Arguments: + sailthru_client: SailthruClient + email (str): user's email address + course_url (str): LMS url for course info page. + unenroll (boolean): True if unenrolling, False if enrolling + Returns: + False if retryable error, else True + """ + try: + # get the user 'vars' values from sailthru + sailthru_response = sailthru_client.api_get("user", {"id": email, "fields": {"vars": 1}}) + if not sailthru_response.is_ok(): + error = sailthru_response.get_error() + log.error("Error attempting to read user record from Sailthru: %s", error.get_message()) + return not _retryable_sailthru_error(error) + + response_json = sailthru_response.json + + unenroll_list = [] + if response_json and "vars" in response_json and response_json["vars"] \ + and "unenrolled" in response_json["vars"]: + unenroll_list = response_json["vars"]["unenrolled"] + + changed = False + # if unenrolling, add course to unenroll list + if unenroll: + if course_url not in unenroll_list: + unenroll_list.append(course_url) + changed = True + + # if enrolling, remove course from unenroll list + elif course_url in unenroll_list: + unenroll_list.remove(course_url) + changed = True + + if changed: + # write user record back + sailthru_response = sailthru_client.api_post( + 'user', {'id': email, 'key': 'email', 'vars': {'unenrolled': unenroll_list}}) + + if not sailthru_response.is_ok(): + error = sailthru_response.get_error() + log.error("Error attempting to update user record in Sailthru: %s", error.get_message()) + return not _retryable_sailthru_error(error) + + return True + + except SailthruClientError as exc: + log.exception("Exception attempting to update user record for %s in Sailthru - %s", email, unicode(exc)) + return False + + +def schedule_retry(self, config): + """Schedule a retry""" + raise self.retry(countdown=config.sailthru_retry_interval, + max_retries=config.sailthru_max_retries) + + +def _get_course_content(course_id, course_url, sailthru_client, config): + """Get course information using the Sailthru content api or from cache. + If there is an error, just return with an empty response. + Arguments: + course_id (str): course key of the course + course_url (str): LMS url for course info page. + sailthru_client : SailthruClient + config : config options + Returns: + course information from Sailthru + """ + # check cache first + + cache_key = "{}:{}".format(course_id, course_url) + response = cache.get(cache_key) + if not response: + try: + sailthru_response = sailthru_client.api_get("content", {"id": course_url}) + if not sailthru_response.is_ok(): + log.error('Could not get course data from Sailthru on enroll/unenroll event. ') + response = {} + else: + response = sailthru_response.json + cache.set(cache_key, response, config.sailthru_content_cache_age) + + except SailthruClientError: + response = {} + + return response + + +def _build_purchase_item(course_id, course_url, cost_in_cents, mode, course_data, sku): + """Build and return Sailthru purchase item object""" + + # build item description + item = { + 'id': "{}-{}".format(course_id, mode), + 'url': course_url, + 'price': cost_in_cents, + 'qty': 1, + } + + # get title from course info if we don't already have it from Sailthru + if 'title' in course_data: + item['title'] = course_data['title'] + else: + # can't find, just invent title + item['title'] = 'Course {} mode: {}'.format(course_id, mode) + + if 'tags' in course_data: + item['tags'] = course_data['tags'] + + return item + + +def _record_purchase(sailthru_client, email, item, options): + """ + Record a purchase in Sailthru + Arguments: + sailthru_client: SailthruClient + email: user's email address + item: Sailthru required information + options: Sailthru purchase API options + Returns: + False if retryable error, else True + """ + + try: + sailthru_response = sailthru_client.purchase(email, [item], options=options) + + if not sailthru_response.is_ok(): + error = sailthru_response.get_error() + log.error("Error attempting to record purchase in Sailthru: %s", error.get_message()) + return not _retryable_sailthru_error(error) + + except SailthruClientError as exc: + log.exception("Exception attempting to record purchase for %s in Sailthru - %s", email, unicode(exc)) + return False + return True diff --git a/lms/djangoapps/email_marketing/tests/test_signals.py b/lms/djangoapps/email_marketing/tests/test_signals.py index bc0e78d71e..78b731cbb0 100644 --- a/lms/djangoapps/email_marketing/tests/test_signals.py +++ b/lms/djangoapps/email_marketing/tests/test_signals.py @@ -19,7 +19,8 @@ from email_marketing.models import EmailMarketingConfiguration from email_marketing.signals import ( add_email_marketing_cookies, email_marketing_register_user, - email_marketing_user_field_changed + email_marketing_user_field_changed, + update_sailthru ) from email_marketing.tasks import ( _create_user_list, @@ -27,11 +28,12 @@ from email_marketing.tasks import ( _get_or_create_user_list, update_user, update_user_email, - get_email_cookies_via_sailthru + get_email_cookies_via_sailthru, + update_course_enrollment, ) from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from student.models import Registration -from student.tests.factories import UserFactory, UserProfileFactory +from student.tests.factories import UserFactory, UserProfileFactory, CourseEnrollmentFactory from util.json_request import JsonResponse log = logging.getLogger(__name__) @@ -89,7 +91,7 @@ class EmailMarketingTests(TestCase): @freeze_time(datetime.datetime.now()) @patch('email_marketing.signals.crum.get_current_request') - @patch('email_marketing.signals.SailthruClient.api_post') + @patch('sailthru.sailthru_client.SailthruClient.api_post') def test_drop_cookie(self, mock_sailthru, mock_get_current_request): """ Test add_email_marketing_cookies @@ -127,7 +129,7 @@ class EmailMarketingTests(TestCase): self.assertTrue('sailthru_hid' in response.cookies) self.assertEquals(response.cookies['sailthru_hid'].value, "test_cookie") - @patch('email_marketing.signals.SailthruClient.api_post') + @patch('sailthru.sailthru_client.SailthruClient.api_post') def test_get_cookies_via_sailthu(self, mock_sailthru): cookies = {'cookie': 'test_cookie'} @@ -149,7 +151,7 @@ class EmailMarketingTests(TestCase): self.assertEqual(cookies['cookie'], expected_cookie.result) - @patch('email_marketing.signals.SailthruClient.api_post') + @patch('sailthru.sailthru_client.SailthruClient.api_post') def test_drop_cookie_error_path(self, mock_sailthru): """ test that error paths return no cookie @@ -523,3 +525,103 @@ class EmailMarketingTests(TestCase): update_email_marketing_config(enabled=False) email_marketing_user_field_changed(None, self.user, table='auth_user', setting='email', old_value='new@a.com') self.assertFalse(mock_update_user.called) + + +class MockSailthruResponse(object): + """ + Mock object for SailthruResponse + """ + + def __init__(self, json_response, error=None, code=1): + self.json = json_response + self.error = error + self.code = code + + def is_ok(self): + """ + Return true of no error + """ + return self.error is None + + def get_error(self): + """ + Get error description + """ + return MockSailthruError(self.error, self.code) + + +class MockSailthruError(object): + """ + Mock object for Sailthru Error + """ + + def __init__(self, error, code=1): + self.error = error + self.code = code + + def get_message(self): + """ + Get error description + """ + return self.error + + def get_error_code(self): + """ + Get error code + """ + return self.code + + +class SailthruTests(TestCase): + """ + Tests for the Sailthru tasks class. + """ + + def setUp(self): + super(SailthruTests, self).setUp() + self.user = UserFactory() + self.course_id = CourseKey.from_string('edX/toy/2012_Fall') + self.course_url = 'http://lms.testserver.fake/courses/edX/toy/2012_Fall/info' + self.course_id2 = 'edX/toy/2016_Fall' + self.course_url2 = 'http://lms.testserver.fake/courses/edX/toy/2016_Fall/info' + + @patch('sailthru.sailthru_client.SailthruClient.purchase') + @patch('sailthru.sailthru_client.SailthruClient.api_get') + @patch('sailthru.sailthru_client.SailthruClient.api_post') + def test_update_course_enrollment(self, mock_sailthru_api_post, + mock_sailthru_api_get, mock_sailthru_purchase): + """test update sailthru user record""" + + # create mocked Sailthru API responses + mock_sailthru_api_post.return_value = MockSailthruResponse({'ok': True}) + mock_sailthru_api_get.return_value = MockSailthruResponse({'user': {"id": TEST_EMAIL, "fields": {"vars": 1}}}) + mock_sailthru_purchase.return_value = MockSailthruResponse({'ok': True}) + self.user.email = TEST_EMAIL + CourseEnrollmentFactory(user=self.user, course_id=self.course_id) + with patch('email_marketing.tasks.build_course_url') as m: + m.return_value = self.course_url + update_course_enrollment(TEST_EMAIL, self.course_id, 'audit') + item = [{ + 'url': self.course_url, + 'price': 0, + 'qty': 1, + 'id': 'edX/toy/2012_Fall-audit', + 'title': 'Course edX/toy/2012_Fall mode: audit' + }] + mock_sailthru_purchase.assert_called_with(TEST_EMAIL, item, options={}) + + @patch('sailthru.sailthru_client.SailthruClient.purchase') + def test_switch_is_disabled(self, mock_sailthru_purchase): + """Make sure sailthru purchase is not called when waffle switch is disabled""" + update_sailthru(None, None, self.user, 'verified', self.course_id) + self.assertFalse(mock_sailthru_purchase.called) + + @patch('openedx.core.djangoapps.waffle_utils.WaffleSwitchNamespace.is_enabled') + @patch('sailthru.sailthru_client.SailthruClient.purchase') + def test_purchase_is_not_invoked(self, mock_sailthru_purchase, switch): + """Make sure purchase is not called in the following condition: + i: waffle switch is True and mode is verified + """ + switch.return_value = True + update_sailthru(None, None, self.user, 'verified', self.course_id) + self.assertFalse(mock_sailthru_purchase.called) From e9625aa75de8c36bfdc7a0d969287a21d8b27908 Mon Sep 17 00:00:00 2001 From: Matt Drayer Date: Tue, 31 Oct 2017 15:43:57 -0400 Subject: [PATCH 29/47] mattdrayer/ENT-686: Log SuccessFactors error response headers --- common/djangoapps/third_party_auth/saml.py | 6 ++++-- .../tests/specs/test_testshib.py | 20 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/common/djangoapps/third_party_auth/saml.py b/common/djangoapps/third_party_auth/saml.py index df3c45a71f..f0fa2ee7f8 100644 --- a/common/djangoapps/third_party_auth/saml.py +++ b/common/djangoapps/third_party_auth/saml.py @@ -310,14 +310,16 @@ class SapSuccessFactorsIdentityProvider(EdXSAMLIdentityProvider): sys_msg = err.response.json() if err.response else "Not available" log_msg_template = ( 'Unable to retrieve user details with username {username} from SAPSuccessFactors for company ' + - 'ID {company} with url "{url}". Error message: {err_msg}. System message: {sys_msg}.' + 'ID {company} with url "{url}". Error message: {err_msg}. System message: {sys_msg}. ' + + 'Headers: {headers}' ) log_msg = log_msg_template.format( username=username, company=self.odata_company_id, url=odata_api_url, err_msg=err.message, - sys_msg=sys_msg + sys_msg=sys_msg, + headers=err.response.headers ) log.warning(log_msg, exc_info=True) return details diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index 3ac2c3bb7d..6947212e81 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -327,6 +327,8 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes """ Return a 500 error when someone tries to call the URL. """ + headers['CorrelationId'] = 'aefd38b7-c92c-445a-8c7a-487a3f0c7a9d' + headers['RequestNo'] = '[787177]' # This is the format SAPSF returns for the transaction request number return 500, headers, 'Failure!' fields = ','.join(SapSuccessFactorsIdentityProvider.default_field_mapping.copy()) @@ -516,16 +518,14 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes ) with LogCapture(level=logging.WARNING) as log_capture: super(SuccessFactorsIntegrationTest, self).test_register() - expected_message = 'Unable to retrieve user details with username {username} from SAPSuccessFactors ' \ - 'for company ID {company_id} with url "{odata_api_url}". Error message: ' \ - '500 Server Error: Internal Server Error for url: {odata_api_url}. System message: ' \ - 'Not available.'.format( - username=self.USER_USERNAME, - company_id=odata_company_id, - odata_api_url=mocked_odata_ai_url, - ) - logging_messages = [log_msg.getMessage() for log_msg in log_capture.records] - self.assertTrue(expected_message in logging_messages) + logging_messages = str([log_msg.getMessage() for log_msg in log_capture.records]).replace('\\', '') + self.assertIn(odata_company_id, logging_messages) + self.assertIn(mocked_odata_ai_url, logging_messages) + self.assertIn(self.USER_USERNAME, logging_messages) + self.assertIn("SAPSuccessFactors", logging_messages) + self.assertIn("Error message", logging_messages) + self.assertIn("System message", logging_messages) + self.assertIn("Headers", logging_messages) @skip('Test not necessary for this subclass') def test_get_saml_idp_class_with_fake_identifier(self): From 8bae7785eea8dcae041c26132bfdce35775714c1 Mon Sep 17 00:00:00 2001 From: Gregory Martin Date: Mon, 30 Oct 2017 14:56:25 -0400 Subject: [PATCH 30/47] A11Y Update to wiki edit field. EDUCATOR-1553 --- lms/djangoapps/course_wiki/editors.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/course_wiki/editors.py b/lms/djangoapps/course_wiki/editors.py index 0f37e3fcc4..faabb5cff0 100644 --- a/lms/djangoapps/course_wiki/editors.py +++ b/lms/djangoapps/course_wiki/editors.py @@ -11,8 +11,12 @@ from wiki.editors.markitup import MarkItUpAdminWidget class CodeMirrorWidget(forms.Widget): def __init__(self, attrs=None): # The 'rows' and 'cols' attributes are required for HTML correctness. - default_attrs = {'class': 'markItUp', - 'rows': '10', 'cols': '40', } + default_attrs = { + 'class': 'markItUp', + 'rows': '10', + 'cols': '40', + 'aria-describedby': 'hint_id_content' + } if attrs: default_attrs.update(attrs) super(CodeMirrorWidget, self).__init__(default_attrs) From 2c6f2f657863d570b3834c18d9cd64301cac7980 Mon Sep 17 00:00:00 2001 From: Bill Filler Date: Wed, 1 Nov 2017 15:43:06 -0400 Subject: [PATCH 31/47] fix dashboard error on edx.org theme --- themes/edx.org/lms/templates/dashboard.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html index 47ab75bf79..b9af125de1 100644 --- a/themes/edx.org/lms/templates/dashboard.html +++ b/themes/edx.org/lms/templates/dashboard.html @@ -125,7 +125,8 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> <% related_programs = inverted_programs.get(unicode(enrollment.course_id)) %> - <%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs" /> + <% show_consent_link = (enrollment.course_id in consent_required_courses) %> + <%include file = 'dashboard/_dashboard_course_listing.html' args='course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> % endfor From 908d5f91a581e8d2827b5b99fb7a7756f3f2204a Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Fri, 6 Oct 2017 14:07:54 -0700 Subject: [PATCH 32/47] Makes regen_user fn respect ENABLE_OPENBADGES - Uses pre-existing function to check if badging is enabled - Patch ENABLE_OPENBADGES for failing test --- .../certificates/management/commands/regenerate_user.py | 3 ++- lms/djangoapps/certificates/tests/test_cert_management.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/certificates/management/commands/regenerate_user.py b/lms/djangoapps/certificates/management/commands/regenerate_user.py index b6c441c83a..4d668f1b60 100644 --- a/lms/djangoapps/certificates/management/commands/regenerate_user.py +++ b/lms/djangoapps/certificates/management/commands/regenerate_user.py @@ -9,6 +9,7 @@ from django.core.management.base import BaseCommand, CommandError from opaque_keys.edx.keys import CourseKey from badges.events.course_complete import get_completion_badge +from badges.utils import badges_enabled from certificates.api import regenerate_user_certificates from xmodule.modulestore.django import modulestore @@ -100,7 +101,7 @@ class Command(BaseCommand): course_id ) - if course.issue_badges: + if badges_enabled() and course.issue_badges: badge_class = get_completion_badge(course_id, student) badge = badge_class.get_for_user(student) diff --git a/lms/djangoapps/certificates/tests/test_cert_management.py b/lms/djangoapps/certificates/tests/test_cert_management.py index 5ec7f56342..c203d2038d 100644 --- a/lms/djangoapps/certificates/tests/test_cert_management.py +++ b/lms/djangoapps/certificates/tests/test_cert_management.py @@ -169,6 +169,7 @@ class RegenerateCertificatesTest(CertificateManagementTest): @ddt.data(True, False) @override_settings(CERT_QUEUE='test-queue') + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_OPENBADGES': True}) @patch('certificates.api.XQueueCertInterface', spec=True) def test_clear_badge(self, issue_badges, xqueue): """ From e5c8acb60984e0b9c88915d423b3dc8f4e3d8d9f Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Fri, 20 Oct 2017 12:07:31 -0400 Subject: [PATCH 33/47] Allow communication experiences to be customized per-learner --- openedx/core/djangoapps/schedules/admin.py | 5 +++++ .../migrations/0006_scheduleexperience.py | 22 +++++++++++++++++++ openedx/core/djangoapps/schedules/models.py | 18 +++++++++++++++ .../core/djangoapps/schedules/resolvers.py | 11 ++++++++-- 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py diff --git a/openedx/core/djangoapps/schedules/admin.py b/openedx/core/djangoapps/schedules/admin.py index d1485e519e..e6cb1bfc12 100644 --- a/openedx/core/djangoapps/schedules/admin.py +++ b/openedx/core/djangoapps/schedules/admin.py @@ -4,12 +4,17 @@ from django.utils.translation import ugettext_lazy as _ from . import models +class ScheduleExperienceAdminInline(admin.StackedInline): + model = models.ScheduleExperience + + @admin.register(models.Schedule) class ScheduleAdmin(admin.ModelAdmin): list_display = ('username', 'course_id', 'active', 'start', 'upgrade_deadline') raw_id_fields = ('enrollment',) readonly_fields = ('modified',) search_fields = ('enrollment__user__username', 'enrollment__course_id',) + inlines = (ScheduleExperienceAdminInline,) def username(self, obj): return obj.enrollment.user.username diff --git a/openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py b/openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py new file mode 100644 index 0000000000..4ffca66d46 --- /dev/null +++ b/openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedules', '0005_auto_20171010_1722'), + ] + + operations = [ + migrations.CreateModel( + name='ScheduleExperience', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('experience_type', models.IntegerField(default=0, choices=[(0, b'Recurring Nudge and Upgrade Reminder'), (1, b'Course Updates')])), + ('schedule', models.OneToOneField(related_name='experience', to='schedules.Schedule')), + ], + ), + ] diff --git a/openedx/core/djangoapps/schedules/models.py b/openedx/core/djangoapps/schedules/models.py index 7248e98754..eff9fabd2d 100644 --- a/openedx/core/djangoapps/schedules/models.py +++ b/openedx/core/djangoapps/schedules/models.py @@ -6,6 +6,13 @@ from model_utils.models import TimeStampedModel from config_models.models import ConfigurationModel +EXPERIENCE_TYPES = ( + (0, 'Recurring Nudge and Upgrade Reminder'), + (1, 'Course Updates'), +) +DEFAULT_EXPERIENCE_TYPE = EXPERIENCE_TYPES[0][0] + + class Schedule(TimeStampedModel): enrollment = models.OneToOneField('student.CourseEnrollment', null=False) active = models.BooleanField( @@ -23,6 +30,12 @@ class Schedule(TimeStampedModel): help_text=_('Deadline by which the learner must upgrade to a verified seat') ) + def get_experience_type(self): + if (hasattr(self, 'experience')): + return self.experience.experience_type + else: + return DEFAULT_EXPERIENCE_TYPE + class Meta(object): verbose_name = _('Schedule') verbose_name_plural = _('Schedules') @@ -39,3 +52,8 @@ class ScheduleConfig(ConfigurationModel): deliver_upgrade_reminder = models.BooleanField(default=False) enqueue_course_update = models.BooleanField(default=False) deliver_course_update = models.BooleanField(default=False) + + +class ScheduleExperience(models.Model): + schedule = models.OneToOneField(Schedule, related_name='experience') + experience_type = models.IntegerField(choices=EXPERIENCE_TYPES, default=DEFAULT_EXPERIENCE_TYPE) diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index d2da8f31da..4770ef32ec 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -18,7 +18,7 @@ from courseware.date_summary import verified_upgrade_deadline_link, verified_upg from openedx.core.djangoapps.monitoring_utils import function_trace, set_custom_metric from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist -from openedx.core.djangoapps.schedules.models import Schedule +from openedx.core.djangoapps.schedules.models import DEFAULT_EXPERIENCE_TYPE, EXPERIENCE_TYPES, Schedule from openedx.core.djangoapps.schedules.utils import PrefixedDebugLoggerMixin from openedx.core.djangoapps.schedules.template_context import ( absolute_url, @@ -64,6 +64,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): relative to. For example, if this resolver finds schedules that started 7 days ago this variable should be set to "start". num_bins -- the int number of bins to split the users into + experience_type -- the string name for the experience type that users will be filtered to """ async_send_task = attr.ib() site = attr.ib() @@ -74,6 +75,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): schedule_date_field = None num_bins = DEFAULT_NUM_BINS + experience_type = DEFAULT_EXPERIENCE_TYPE def __attrs_post_init__(self): # TODO: in the next refactor of this task, pass in current_datetime instead of reproducing it here @@ -123,10 +125,14 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): 'enrollment__user__profile', 'enrollment__course', ).prefetch_related( - 'enrollment__course__modes' + 'enrollment__course__modes', + 'experience', ).filter( Q(enrollment__course__end__isnull=True) | Q( enrollment__course__end__gte=self.current_datetime), + Q(experience__isnull=True) | Q(experience__experience_type=self.experience_type) + if self.experience_type == DEFAULT_EXPERIENCE_TYPE else + Q(experience__isnull=False) & Q(experience__experience_type=self.experience_type), enrollment__user__in=users, enrollment__is_active=True, **schedule_day_equals_target_day_filter @@ -333,6 +339,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver): log_prefix = 'Course Update' schedule_date_field = 'start' num_bins = COURSE_UPDATE_NUM_BINS + experience_type = EXPERIENCE_TYPES[1][0] def schedules_for_bin(self): week_num = abs(self.day_offset) / 7 From 95d1e5c25e254101ccac2ff6d7a1dad5b2e3e435 Mon Sep 17 00:00:00 2001 From: Gabe Mulley Date: Fri, 27 Oct 2017 08:41:31 -0400 Subject: [PATCH 34/47] use a left outer join for experience types --- .../commands/tests/send_email_base.py | 4 +- .../commands/tests/test_experiences.py | 59 +++++++++++++++++++ .../migrations/0006_scheduleexperience.py | 2 +- openedx/core/djangoapps/schedules/models.py | 22 +++---- .../core/djangoapps/schedules/resolvers.py | 23 +++++--- .../djangoapps/schedules/tests/factories.py | 8 +++ 6 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py index db773e482d..a7eee64467 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py @@ -19,8 +19,8 @@ from openedx.core.djangoapps.schedules import resolvers, tasks from openedx.core.djangoapps.schedules.resolvers import _get_datetime_beginning_of_day from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.djangolib.testing.utils import FilteredQueryCountMixin, CacheIsolationTestCase from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase SITE_QUERY = 2 # django_site, site_configuration_siteconfiguration @@ -56,7 +56,7 @@ LOG = logging.getLogger(__name__) @ddt.ddt @freeze_time('2017-08-01 00:00:00', tz_offset=0, tick=True) -class ScheduleSendEmailTestBase(SharedModuleStoreTestCase): +class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase): __test__ = False diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py new file mode 100644 index 0000000000..d026bf24dc --- /dev/null +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py @@ -0,0 +1,59 @@ +from collections import namedtuple + +import datetime +import ddt +import pytz +from edx_ace.utils.date import serialize +from freezegun import freeze_time +from mock import patch + +from courseware.models import DynamicUpgradeDeadlineConfiguration +from openedx.core.djangoapps.schedules import tasks +from openedx.core.djangoapps.schedules.models import ScheduleExperience +from openedx.core.djangoapps.schedules.resolvers import _get_datetime_beginning_of_day +from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory, ScheduleConfigFactory +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory, SiteConfigurationFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms, FilteredQueryCountMixin, CacheIsolationTestCase + + +@ddt.ddt +@skip_unless_lms +@freeze_time('2017-08-01 00:00:00', tz_offset=0, tick=True) +class TestExperiences(FilteredQueryCountMixin, CacheIsolationTestCase): + + ENABLED_CACHES = ['default'] + + ExperienceTest = namedtuple('ExperienceTest', 'experience offset email_sent') + + def setUp(self): + super(TestExperiences, self).setUp() + + site = SiteFactory.create() + self.site_config = SiteConfigurationFactory.create(site=site) + ScheduleConfigFactory.create(site=self.site_config.site) + + DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) + + @ddt.data( + ExperienceTest(experience=ScheduleExperience.DEFAULT, offset=-3, email_sent=True), + ExperienceTest(experience=ScheduleExperience.DEFAULT, offset=-10, email_sent=True), + ExperienceTest(experience=ScheduleExperience.COURSE_UPDATES, offset=-3, email_sent=True), + ExperienceTest(experience=ScheduleExperience.COURSE_UPDATES, offset=-10, email_sent=False), + ) + @patch.object(tasks, 'ace') + def test_experience_type_exclusion(self, test_config, mock_ace): + current_day = _get_datetime_beginning_of_day(datetime.datetime.now(pytz.UTC)) + target_day = current_day + datetime.timedelta(days=test_config.offset) + + schedule = ScheduleFactory.create( + start=target_day, + enrollment__course__self_paced=True, + experience__experience_type=test_config.experience, + ) + + tasks.ScheduleRecurringNudge.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=test_config.offset, + bin_num=(schedule.enrollment.user.id % tasks.ScheduleRecurringNudge.num_bins), + )) + + self.assertEqual(mock_ace.send.called, test_config.email_sent) diff --git a/openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py b/openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py index 4ffca66d46..df0b41412b 100644 --- a/openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py +++ b/openedx/core/djangoapps/schedules/migrations/0006_scheduleexperience.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): name='ScheduleExperience', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('experience_type', models.IntegerField(default=0, choices=[(0, b'Recurring Nudge and Upgrade Reminder'), (1, b'Course Updates')])), + ('experience_type', models.PositiveSmallIntegerField(default=0, choices=[(0, b'Recurring Nudge and Upgrade Reminder'), (1, b'Course Updates')])), ('schedule', models.OneToOneField(related_name='experience', to='schedules.Schedule')), ], ), diff --git a/openedx/core/djangoapps/schedules/models.py b/openedx/core/djangoapps/schedules/models.py index eff9fabd2d..adf6cbd454 100644 --- a/openedx/core/djangoapps/schedules/models.py +++ b/openedx/core/djangoapps/schedules/models.py @@ -6,13 +6,6 @@ from model_utils.models import TimeStampedModel from config_models.models import ConfigurationModel -EXPERIENCE_TYPES = ( - (0, 'Recurring Nudge and Upgrade Reminder'), - (1, 'Course Updates'), -) -DEFAULT_EXPERIENCE_TYPE = EXPERIENCE_TYPES[0][0] - - class Schedule(TimeStampedModel): enrollment = models.OneToOneField('student.CourseEnrollment', null=False) active = models.BooleanField( @@ -31,10 +24,10 @@ class Schedule(TimeStampedModel): ) def get_experience_type(self): - if (hasattr(self, 'experience')): + try: return self.experience.experience_type - else: - return DEFAULT_EXPERIENCE_TYPE + except ScheduleExperience.DoesNotExist: + return ScheduleExperience.DEFAULT class Meta(object): verbose_name = _('Schedule') @@ -55,5 +48,12 @@ class ScheduleConfig(ConfigurationModel): class ScheduleExperience(models.Model): + DEFAULT = 0 + COURSE_UPDATES = 1 + EXPERIENCES = ( + (DEFAULT, 'Recurring Nudge and Upgrade Reminder'), + (COURSE_UPDATES, 'Course Updates') + ) + schedule = models.OneToOneField(Schedule, related_name='experience') - experience_type = models.IntegerField(choices=EXPERIENCE_TYPES, default=DEFAULT_EXPERIENCE_TYPE) + experience_type = models.PositiveSmallIntegerField(choices=EXPERIENCES, default=DEFAULT) diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 4770ef32ec..05ba2b4b6b 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -18,7 +18,7 @@ from courseware.date_summary import verified_upgrade_deadline_link, verified_upg from openedx.core.djangoapps.monitoring_utils import function_trace, set_custom_metric from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_WAFFLE_FLAG from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist -from openedx.core.djangoapps.schedules.models import DEFAULT_EXPERIENCE_TYPE, EXPERIENCE_TYPES, Schedule +from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperience from openedx.core.djangoapps.schedules.utils import PrefixedDebugLoggerMixin from openedx.core.djangoapps.schedules.template_context import ( absolute_url, @@ -64,7 +64,9 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): relative to. For example, if this resolver finds schedules that started 7 days ago this variable should be set to "start". num_bins -- the int number of bins to split the users into - experience_type -- the string name for the experience type that users will be filtered to + experience_filter -- a queryset filter used to select only the users who should be getting this message as part + of their experience. This defaults to users without a specified experience type and those + in the "recurring nudges and upgrade reminder" experience. """ async_send_task = attr.ib() site = attr.ib() @@ -75,7 +77,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): schedule_date_field = None num_bins = DEFAULT_NUM_BINS - experience_type = DEFAULT_EXPERIENCE_TYPE + experience_filter = Q(experience__experience_type=ScheduleExperience.DEFAULT) | Q(experience__isnull=True) def __attrs_post_init__(self): # TODO: in the next refactor of this task, pass in current_datetime instead of reproducing it here @@ -126,13 +128,10 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): 'enrollment__course', ).prefetch_related( 'enrollment__course__modes', - 'experience', ).filter( Q(enrollment__course__end__isnull=True) | Q( enrollment__course__end__gte=self.current_datetime), - Q(experience__isnull=True) | Q(experience__experience_type=self.experience_type) - if self.experience_type == DEFAULT_EXPERIENCE_TYPE else - Q(experience__isnull=False) & Q(experience__experience_type=self.experience_type), + self.experience_filter, enrollment__user__in=users, enrollment__is_active=True, **schedule_day_equals_target_day_filter @@ -238,6 +237,14 @@ class RecurringNudgeResolver(BinnedSchedulesBaseResolver): schedule_date_field = 'start' num_bins = RECURRING_NUDGE_NUM_BINS + @property + def experience_filter(self): + if self.day_offset == -3: + experiences = [ScheduleExperience.DEFAULT, ScheduleExperience.COURSE_UPDATES] + return Q(experience__experience_type__in=experiences) | Q(experience__isnull=True) + else: + return Q(experience__experience_type=ScheduleExperience.DEFAULT) | Q(experience__isnull=True) + def get_template_context(self, user, user_schedules): first_schedule = user_schedules[0] context = { @@ -339,7 +346,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver): log_prefix = 'Course Update' schedule_date_field = 'start' num_bins = COURSE_UPDATE_NUM_BINS - experience_type = EXPERIENCE_TYPES[1][0] + experience_filter = Q(experience__experience_type=ScheduleExperience.COURSE_UPDATES) def schedules_for_bin(self): week_num = abs(self.day_offset) / 7 diff --git a/openedx/core/djangoapps/schedules/tests/factories.py b/openedx/core/djangoapps/schedules/tests/factories.py index affc67cc3b..af6a5875c9 100644 --- a/openedx/core/djangoapps/schedules/tests/factories.py +++ b/openedx/core/djangoapps/schedules/tests/factories.py @@ -6,6 +6,13 @@ from student.tests.factories import CourseEnrollmentFactory from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +class ScheduleExperienceFactory(factory.DjangoModelFactory): + class Meta(object): + model = models.ScheduleExperience + + experience_type = models.ScheduleExperience.DEFAULT + + class ScheduleFactory(factory.DjangoModelFactory): class Meta(object): model = models.Schedule @@ -13,6 +20,7 @@ class ScheduleFactory(factory.DjangoModelFactory): start = factory.Faker('future_datetime', tzinfo=pytz.UTC) upgrade_deadline = factory.Faker('future_datetime', tzinfo=pytz.UTC) enrollment = factory.SubFactory(CourseEnrollmentFactory) + experience = factory.RelatedFactory(ScheduleExperienceFactory, 'schedule') class ScheduleConfigFactory(factory.DjangoModelFactory): From 57bf89c8e222ca9088e31d59ff9f757e9932991b Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Mon, 30 Oct 2017 13:57:38 -0400 Subject: [PATCH 35/47] Create schedule experience on schedule creation --- openedx/core/djangoapps/schedules/signals.py | 17 ++++++++++++++--- .../djangoapps/schedules/tests/test_signals.py | 12 +++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/schedules/signals.py b/openedx/core/djangoapps/schedules/signals.py index 1af925f22e..45b5927c7f 100644 --- a/openedx/core/djangoapps/schedules/signals.py +++ b/openedx/core/djangoapps/schedules/signals.py @@ -12,6 +12,9 @@ from courseware.models import ( OrgDynamicUpgradeDeadlineConfiguration ) from edx_ace.utils import date +from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist +from openedx.core.djangoapps.schedules.models import ScheduleExperience +from openedx.core.djangoapps.schedules.resolvers import get_week_highlights from openedx.core.djangoapps.signals.signals import COURSE_START_DATE_CHANGED from openedx.core.djangoapps.theming.helpers import get_current_site from student.models import CourseEnrollment @@ -53,14 +56,22 @@ def create_schedule(sender, **kwargs): upgrade_deadline = _calculate_upgrade_deadline(enrollment.course_id, content_availability_date) - Schedule.objects.create( + schedule = Schedule.objects.create( enrollment=enrollment, start=content_availability_date, upgrade_deadline=upgrade_deadline ) - log.debug('Schedules: created a new schedule starting at %s with an upgrade deadline of %s', - content_availability_date, upgrade_deadline) + try: + get_week_highlights(enrollment.course_id, 1) + experience_type = ScheduleExperience.COURSE_UPDATES + except CourseUpdateDoesNotExist: + experience_type = ScheduleExperience.DEFAULT + + ScheduleExperience(schedule=schedule, experience_type=experience_type).save() + + log.debug('Schedules: created a new schedule starting at %s with an upgrade deadline of %s and experience type: %s', + content_availability_date, upgrade_deadline, ScheduleExperience.EXPERIENCES[experience_type][1]) @receiver(COURSE_START_DATE_CHANGED, dispatch_uid="update_schedules_on_course_start_changed") diff --git a/openedx/core/djangoapps/schedules/tests/test_signals.py b/openedx/core/djangoapps/schedules/tests/test_signals.py index 1bf0048c5e..98b6f48f67 100644 --- a/openedx/core/djangoapps/schedules/tests/test_signals.py +++ b/openedx/core/djangoapps/schedules/tests/test_signals.py @@ -6,6 +6,7 @@ from pytz import utc from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from courseware.models import DynamicUpgradeDeadlineConfiguration +from openedx.core.djangoapps.schedules.models import ScheduleExperience from openedx.core.djangoapps.schedules.signals import CREATE_SCHEDULE_WAFFLE_FLAG from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag @@ -23,11 +24,12 @@ from ..tests.factories import ScheduleConfigFactory @skip_unless_lms class CreateScheduleTests(SharedModuleStoreTestCase): - def assert_schedule_created(self): + def assert_schedule_created(self, experience_type=ScheduleExperience.DEFAULT): course = _create_course_run(self_paced=True) enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT) self.assertIsNotNone(enrollment.schedule) self.assertIsNone(enrollment.schedule.upgrade_deadline) + self.assertEquals(enrollment.schedule.experience.experience_type, experience_type) def assert_schedule_not_created(self): course = _create_course_run(self_paced=True) @@ -78,6 +80,14 @@ class CreateScheduleTests(SharedModuleStoreTestCase): with self.assertRaises(Schedule.DoesNotExist): enrollment.schedule + @override_waffle_flag(CREATE_SCHEDULE_WAFFLE_FLAG, True) + @patch('openedx.core.djangoapps.schedules.signals.get_week_highlights') + def test_create_schedule_course_updates_experience(self, mock_get_week_highlights, mock_get_current_site): + site = SiteFactory.create() + mock_get_week_highlights.return_value = True + mock_get_current_site.return_value = site + self.assert_schedule_created(experience_type=ScheduleExperience.COURSE_UPDATES) + @ddt.ddt @skip_unless_lms From 7fd643faa40932dd114640e885bf8f9cc5d2d27d Mon Sep 17 00:00:00 2001 From: Gabe Mulley Date: Mon, 30 Oct 2017 16:16:46 -0400 Subject: [PATCH 36/47] Add tests for experience types, ensure courses have a verified mode --- common/djangoapps/student/models.py | 24 +-- common/djangoapps/student/tests/factories.py | 32 +++- .../student/tests/test_certificates.py | 2 + .../certificates/tests/test_webview_views.py | 6 +- lms/djangoapps/courseware/date_summary.py | 15 +- .../tests/test_view_authentication.py | 1 + lms/djangoapps/courseware/tests/test_views.py | 7 +- lms/djangoapps/discussion/tests/test_views.py | 8 +- .../commands/tests/send_email_base.py | 140 +++++++++++------- .../commands/tests/test_experiences.py | 59 -------- .../commands/tests/test_send_course_update.py | 19 ++- .../tests/test_send_recurring_nudge.py | 17 ++- .../tests/test_send_upgrade_reminder.py | 37 +++-- .../management/commands/tests/upsell_base.py | 7 +- openedx/core/djangoapps/schedules/models.py | 13 +- .../core/djangoapps/schedules/resolvers.py | 13 +- openedx/core/djangoapps/schedules/signals.py | 6 +- .../djangoapps/schedules/tests/factories.py | 2 +- .../schedules/tests/test_signals.py | 19 ++- 19 files changed, 246 insertions(+), 181 deletions(-) delete mode 100644 openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index ab269f49b8..2ef11c0dbc 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1686,9 +1686,13 @@ class CourseEnrollment(models.Model): """ if not self._course_overview: try: - self._course_overview = CourseOverview.get_from_id(self.course_id) - except (CourseOverview.DoesNotExist, IOError): - self._course_overview = None + self._course_overview = self.course + except CourseOverview.DoesNotExist: + log.info('Course Overviews: unable to find course overview for enrollment, loading from modulestore.') + try: + self._course_overview = CourseOverview.get_from_id(self.course_id) + except (CourseOverview.DoesNotExist, IOError): + self._course_overview = None return self._course_overview @cached_property @@ -1717,7 +1721,14 @@ class CourseEnrollment(models.Model): # When course modes expire they aren't found any more and None would be returned. # Replicate that behavior here by returning None if the personalized deadline is in the past. if datetime.now(UTC) >= self.dynamic_upgrade_deadline: + log.debug('Schedules: Returning None since dynamic upgrade deadline has already passed.') return None + + if self.verified_mode is None: + log.debug('Schedules: Returning None for dynamic upgrade deadline since the course does not have a ' + 'verified mode.') + return None + return self.dynamic_upgrade_deadline return self.course_upgrade_deadline @@ -1733,12 +1744,7 @@ class CourseEnrollment(models.Model): Returns: datetime|None """ - try: - course_overview = self.course - except CourseOverview.DoesNotExist: - course_overview = self.course_overview - - if not course_overview.self_paced: + if not self.course_overview.self_paced: return None if not DynamicUpgradeDeadlineConfiguration.is_enabled(): diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 7099da2976..4dd23216b9 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -12,6 +12,8 @@ from opaque_keys.edx.keys import CourseKey from pytz import UTC from course_modes.models import CourseMode +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from student.models import ( CourseAccessRole, CourseEnrollment, @@ -126,9 +128,33 @@ class CourseEnrollmentFactory(DjangoModelFactory): model = CourseEnrollment user = factory.SubFactory(UserFactory) - course = factory.SubFactory( - 'openedx.core.djangoapps.content.course_overviews.tests.factories.CourseOverviewFactory', - ) + + @classmethod + def _create(cls, model_class, *args, **kwargs): + manager = cls._get_manager(model_class) + course_kwargs = {} + for key in kwargs.keys(): + if key.startswith('course__'): + course_kwargs[key.split('__')[1]] = kwargs.pop(key) + + if 'course' not in kwargs: + course_id = kwargs.get('course_id') + course_overview = None + if course_id is not None: + if isinstance(course_id, basestring): + course_id = CourseKey.from_string(course_id) + course_kwargs.setdefault('id', course_id) + + try: + course_overview = CourseOverview.get_from_id(course_id) + except CourseOverview.DoesNotExist: + pass + + if course_overview is None: + course_overview = CourseOverviewFactory(**course_kwargs) + kwargs['course'] = course_overview + + return manager.create(*args, **kwargs) class CourseAccessRoleFactory(DjangoModelFactory): diff --git a/common/djangoapps/student/tests/test_certificates.py b/common/djangoapps/student/tests/test_certificates.py index 3906f0b356..47749dea3e 100644 --- a/common/djangoapps/student/tests/test_certificates.py +++ b/common/djangoapps/student/tests/test_certificates.py @@ -111,6 +111,8 @@ class CertificateDashboardMessageDisplayTest(CertificateDisplayTestBase): Tests the certificates messages for a course in the dashboard. """ + ENABLED_SIGNALS = ['course_published'] + @classmethod def setUpClass(cls): super(CertificateDashboardMessageDisplayTest, cls).setUpClass() diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index 899af116ac..59fdd905ae 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -12,8 +12,9 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.test.client import Client, RequestFactory from django.test.utils import override_settings + from util.date_utils import strftime_localized -from mock import Mock, patch +from mock import patch from nose.plugins.attrib import attr from certificates.api import get_certificate_url @@ -73,6 +74,9 @@ class CommonCertificatesTestCase(ModuleStoreTestCase): """ Common setUp and utility methods for Certificate tests """ + + ENABLED_SIGNALS = ['course_published'] + def setUp(self): super(CommonCertificatesTestCase, self).setUp() self.client = Client() diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 1bf76a47ba..0a541754f5 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -405,16 +405,19 @@ def verified_upgrade_deadline_link(user, course=None, course_id=None): ecommerce_service = EcommerceService() if ecommerce_service.is_enabled(user): - if course is not None and isinstance(course, CourseOverview): - course_mode = course.modes.get(mode_slug=CourseMode.VERIFIED) + course_mode = CourseMode.verified_mode_for_course(course_id) + if course_mode is not None: + return ecommerce_service.get_checkout_page_url(course_mode.sku) else: - course_mode = CourseMode.objects.get( - course_id=course_id, mode_slug=CourseMode.VERIFIED - ) - return ecommerce_service.get_checkout_page_url(course_mode.sku) + raise CourseModeNotFoundException('Cannot generate a verified upgrade link without a valid verified mode' + ' for course {}'.format(unicode(course_id))) return reverse('verify_student_upgrade_and_verify', args=(course_id,)) +class CourseModeNotFoundException(Exception): + pass + + def verified_upgrade_link_is_valid(enrollment=None): """ Return whether this enrollment can be upgraded. diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 5d8195ad53..1c2cc27469 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -29,6 +29,7 @@ class TestViewAuth(EnterpriseTestConsentRequired, ModuleStoreTestCase, LoginEnro """ ACCOUNT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] + ENABLED_SIGNALS = ['course_published'] @staticmethod def _reverse_urls(names, course): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index c115db93b9..341ebc3d23 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -807,7 +807,12 @@ class ViewsTestCase(ModuleStoreTestCase): CourseModeFactory.create(mode_slug=CourseMode.VERIFIED, course_id=course) # Enroll user in the course - enrollment = CourseEnrollmentFactory(course_id=course, user=self.user, mode=CourseMode.AUDIT) + # Don't use the CourseEnrollmentFactory since it ensures a CourseOverview is available + enrollment = CourseEnrollment.objects.create( + course_id=course, + user=self.user, + mode=CourseMode.AUDIT, + ) self.assertEqual(enrollment.course_overview, None) diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index 65faab5325..c2b113e2b9 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -403,15 +403,15 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): # course is outside the context manager that is verifying the number of queries, # and with split mongo, that method ends up querying disabled_xblocks (which is then # cached and hence not queried as part of call_single_thread). - (ModuleStoreEnum.Type.mongo, False, 1, 6, 4, 17, 4), - (ModuleStoreEnum.Type.mongo, False, 50, 6, 4, 17, 4), + (ModuleStoreEnum.Type.mongo, False, 1, 6, 4, 16, 4), + (ModuleStoreEnum.Type.mongo, False, 50, 6, 4, 16, 4), # split mongo: 3 queries, regardless of thread response size. (ModuleStoreEnum.Type.split, False, 1, 3, 3, 16, 4), (ModuleStoreEnum.Type.split, False, 50, 3, 3, 16, 4), # Enabling Enterprise integration should have no effect on the number of mongo queries made. - (ModuleStoreEnum.Type.mongo, True, 1, 6, 4, 17, 4), - (ModuleStoreEnum.Type.mongo, True, 50, 6, 4, 17, 4), + (ModuleStoreEnum.Type.mongo, True, 1, 6, 4, 16, 4), + (ModuleStoreEnum.Type.mongo, True, 50, 6, 4, 16, 4), # split mongo: 3 queries, regardless of thread response size. (ModuleStoreEnum.Type.split, True, 1, 3, 3, 16, 4), (ModuleStoreEnum.Type.split, True, 50, 3, 3, 16, 4), diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py index a7eee64467..b98f65c7d6 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py @@ -1,3 +1,4 @@ +from collections import namedtuple, defaultdict from copy import deepcopy import datetime import ddt @@ -9,6 +10,9 @@ from freezegun import freeze_time from mock import Mock, patch import pytz +from commerce.models import CommerceConfiguration +from course_modes.models import CourseMode +from course_modes.tests.factories import CourseModeFactory from courseware.models import DynamicUpgradeDeadlineConfiguration from edx_ace.channel import ChannelType from edx_ace.utils.date import serialize @@ -20,10 +24,12 @@ from openedx.core.djangoapps.schedules.resolvers import _get_datetime_beginning_ from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.core.djangolib.testing.utils import FilteredQueryCountMixin, CacheIsolationTestCase +from student.models import CourseEnrollment from student.tests.factories import UserFactory -SITE_QUERY = 2 # django_site, site_configuration_siteconfiguration +SITE_QUERY = 1 # django_site +SITE_CONFIG_QUERY = 1 # site_configuration_siteconfiguration SCHEDULES_QUERY = 1 # schedules_schedule COURSE_MODES_QUERY = 1 # course_modes_coursemode @@ -33,18 +39,14 @@ ORG_DEADLINE_QUERY = 1 # courseware_orgdynamicupgradedeadlineconfiguration COURSE_DEADLINE_QUERY = 1 # courseware_coursedynamicupgradedeadlineconfiguration COMMERCE_CONFIG_QUERY = 1 # commerce_commerceconfiguration -NUM_QUERIES_NO_MATCHING_SCHEDULES = ( +NUM_QUERIES_SITE_SCHEDULES = ( SITE_QUERY + + SITE_CONFIG_QUERY + SCHEDULES_QUERY ) -NUM_QUERIES_WITH_MATCHES = ( - NUM_QUERIES_NO_MATCHING_SCHEDULES + - COURSE_MODES_QUERY -) - NUM_QUERIES_FIRST_MATCH = ( - NUM_QUERIES_WITH_MATCHES + NUM_QUERIES_SITE_SCHEDULES + GLOBAL_DEADLINE_QUERY + ORG_DEADLINE_QUERY + COURSE_DEADLINE_QUERY @@ -54,6 +56,9 @@ NUM_QUERIES_FIRST_MATCH = ( LOG = logging.getLogger(__name__) +ExperienceTest = namedtuple('ExperienceTest', 'experience offset email_sent') + + @ddt.ddt @freeze_time('2017-08-01 00:00:00', tz_offset=0, tick=True) class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase): @@ -73,6 +78,9 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) ScheduleConfigFactory.create(site=self.site_config.site) DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) + CommerceConfiguration.objects.create(checkout_on_ecommerce_service=True) + + self._courses_with_verified_modes = set() def _calculate_bin_for_user(self, user): return user.id % self.task.num_bins @@ -92,6 +100,24 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) templates_override[0]['OPTIONS']['string_if_invalid'] = "TEMPLATE WARNING - MISSING VARIABLE [%s]" return templates_override + def _schedule_factory(self, offset=None, **factory_kwargs): + _, _, target_day, upgrade_deadline = self._get_dates(offset=offset) + factory_kwargs.setdefault('start', target_day) + factory_kwargs.setdefault('upgrade_deadline', upgrade_deadline) + factory_kwargs.setdefault('enrollment__course__self_paced', True) + if hasattr(self, 'experience_type'): + factory_kwargs.setdefault('experience__experience_type', self.experience_type) + schedule = ScheduleFactory(**factory_kwargs) + course_id = schedule.enrollment.course_id + if course_id not in self._courses_with_verified_modes: + CourseModeFactory( + course_id=course_id, + mode_slug=CourseMode.VERIFIED, + expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30), + ) + self._courses_with_verified_modes.add(course_id) + return schedule + def test_command_task_binding(self): self.assertEqual(self.command.async_send_task, self.task) @@ -130,11 +156,7 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) with patch.object(self.task, 'async_send_task') as mock_schedule_send: current_day, offset, target_day, upgrade_deadline = self._get_dates() schedules = [ - ScheduleFactory.create( - start=target_day, - upgrade_deadline=upgrade_deadline, - enrollment__course__self_paced=True, - ) for _ in range(schedule_count) + self._schedule_factory() for _ in range(schedule_count) ] bins_in_use = frozenset((self._calculate_bin_for_user(s.enrollment.user)) for s in schedules) @@ -142,18 +164,17 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) target_day_str = serialize(target_day) for b in range(self.task.num_bins): - LOG.debug('Running bin %d', b) - expected_queries = NUM_QUERIES_NO_MATCHING_SCHEDULES + LOG.debug('Checking bin %d', b) + expected_queries = NUM_QUERIES_SITE_SCHEDULES if b in bins_in_use: if is_first_match: expected_queries = ( # Since this is the first match, we need to cache all of the config models, so we run a # query for each of those... NUM_QUERIES_FIRST_MATCH + + COURSE_MODES_QUERY # to cache the course modes for this course ) is_first_match = False - else: - expected_queries = NUM_QUERIES_WITH_MATCHES with self.assertNumQueries(expected_queries, table_blacklist=WAFFLE_TABLES): self.task.apply(kwargs=dict( @@ -171,13 +192,12 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) def test_no_course_overview(self): current_day, offset, target_day, upgrade_deadline = self._get_dates() - schedule = ScheduleFactory.create( - start=target_day, - upgrade_deadline=upgrade_deadline, - enrollment__course__self_paced=True, + # Don't use CourseEnrollmentFactory since it creates a course overview + enrollment = CourseEnrollment.objects.create( + course_id=CourseKey.from_string('edX/toy/Not_2012_Fall'), + user=UserFactory.create(), ) - schedule.enrollment.course_id = CourseKey.from_string('edX/toy/Not_2012_Fall') - schedule.enrollment.save() + schedule = self._schedule_factory(enrollment=enrollment) with patch.object(self.task, 'async_send_task') as mock_schedule_send: for b in range(self.task.num_bins): @@ -249,25 +269,16 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) user2 = UserFactory.create(id=self.task.num_bins * 2) current_day, offset, target_day, upgrade_deadline = self._get_dates() - ScheduleFactory.create( - upgrade_deadline=upgrade_deadline, - start=target_day, + self._schedule_factory( enrollment__course__org=filtered_org, - enrollment__course__self_paced=True, enrollment__user=user1, ) - ScheduleFactory.create( - upgrade_deadline=upgrade_deadline, - start=target_day, + self._schedule_factory( enrollment__course__org=unfiltered_org, - enrollment__course__self_paced=True, enrollment__user=user1, ) - ScheduleFactory.create( - upgrade_deadline=upgrade_deadline, - start=target_day, + self._schedule_factory( enrollment__course__org=unfiltered_org, - enrollment__course__self_paced=True, enrollment__user=user2, ) @@ -284,17 +295,12 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) user1 = UserFactory.create(id=self.task.num_bins) current_day, offset, target_day, upgrade_deadline = self._get_dates() - schedule = ScheduleFactory.create( - start=target_day, - upgrade_deadline=upgrade_deadline, - enrollment__course__self_paced=True, - enrollment__user=user1, - ) - - schedule.enrollment.course.start = current_day - datetime.timedelta(days=30) end_date_offset = -2 if has_course_ended else 2 - schedule.enrollment.course.end = current_day + datetime.timedelta(days=end_date_offset) - schedule.enrollment.course.save() + self._schedule_factory( + enrollment__user=user1, + enrollment__course__start=current_day - datetime.timedelta(days=30), + enrollment__course__end=current_day + datetime.timedelta(days=end_date_offset) + ) with patch.object(self.task, 'async_send_task') as mock_schedule_send: self.task.apply(kwargs=dict( @@ -312,15 +318,14 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) current_day, offset, target_day, upgrade_deadline = self._get_dates() num_courses = 3 for course_index in range(num_courses): - ScheduleFactory.create( - start=target_day, - upgrade_deadline=upgrade_deadline, - enrollment__course__self_paced=True, + self._schedule_factory( enrollment__user=user, enrollment__course__id=CourseKey.from_string('edX/toy/course{}'.format(course_index)) ) - additional_course_queries = num_courses - 1 if self.queries_deadline_for_each_course else 0 + # 2 queries per course, one for the course opt out and one for the course modes + # one query for course modes for the first schedule if we aren't checking the deadline for each course + additional_course_queries = (num_courses * 2) - 1 if self.queries_deadline_for_each_course else 1 expected_query_count = NUM_QUERIES_FIRST_MATCH + additional_course_queries with self.assertNumQueries(expected_query_count, table_blacklist=WAFFLE_TABLES): with patch.object(self.task, 'async_send_task') as mock_schedule_send: @@ -333,7 +338,9 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) self.assertEqual(mock_schedule_send.apply_async.call_count, expected_call_count) self.assertFalse(mock_ace.send.called) - @ddt.data(1, 10, 100) + @ddt.data( + 1, 10 + ) def test_templates(self, message_count): for offset in self.expected_offsets: self._assert_template_for_offset(offset, message_count) @@ -344,10 +351,8 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) user = UserFactory.create() for course_index in range(message_count): - ScheduleFactory.create( - start=target_day, - upgrade_deadline=upgrade_deadline, - enrollment__course__self_paced=True, + self._schedule_factory( + offset=offset, enrollment__user=user, enrollment__course__id=CourseKey.from_string('edX/toy/course{}'.format(course_index)) ) @@ -366,7 +371,10 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) num_expected_queries = NUM_QUERIES_FIRST_MATCH if self.queries_deadline_for_each_course: - num_expected_queries += (message_count - 1) + # one query per course for opt-out and one for course modes + num_expected_queries += (message_count * 2) - 1 + else: + num_expected_queries += 1 with self.assertNumQueries(num_expected_queries, table_blacklist=WAFFLE_TABLES): self.task.apply(kwargs=dict( @@ -385,3 +393,23 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase) self.assertNotIn("TEMPLATE WARNING", template) self.assertNotIn("{{", template) self.assertNotIn("}}", template) + + def _check_if_email_sent_for_experience(self, test_config): + current_day, offset, target_day, _ = self._get_dates(offset=test_config.offset) + + kwargs = { + 'offset': offset + } + if test_config.experience is None: + kwargs['experience'] = None + else: + kwargs['experience__experience_type'] = test_config.experience + schedule = self._schedule_factory(**kwargs) + + with patch.object(tasks, 'ace') as mock_ace: + self.task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, + bin_num=self._calculate_bin_for_user(schedule.enrollment.user), + )) + + self.assertEqual(mock_ace.send.called, test_config.email_sent) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py deleted file mode 100644 index d026bf24dc..0000000000 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_experiences.py +++ /dev/null @@ -1,59 +0,0 @@ -from collections import namedtuple - -import datetime -import ddt -import pytz -from edx_ace.utils.date import serialize -from freezegun import freeze_time -from mock import patch - -from courseware.models import DynamicUpgradeDeadlineConfiguration -from openedx.core.djangoapps.schedules import tasks -from openedx.core.djangoapps.schedules.models import ScheduleExperience -from openedx.core.djangoapps.schedules.resolvers import _get_datetime_beginning_of_day -from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory, ScheduleConfigFactory -from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory, SiteConfigurationFactory -from openedx.core.djangolib.testing.utils import skip_unless_lms, FilteredQueryCountMixin, CacheIsolationTestCase - - -@ddt.ddt -@skip_unless_lms -@freeze_time('2017-08-01 00:00:00', tz_offset=0, tick=True) -class TestExperiences(FilteredQueryCountMixin, CacheIsolationTestCase): - - ENABLED_CACHES = ['default'] - - ExperienceTest = namedtuple('ExperienceTest', 'experience offset email_sent') - - def setUp(self): - super(TestExperiences, self).setUp() - - site = SiteFactory.create() - self.site_config = SiteConfigurationFactory.create(site=site) - ScheduleConfigFactory.create(site=self.site_config.site) - - DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) - - @ddt.data( - ExperienceTest(experience=ScheduleExperience.DEFAULT, offset=-3, email_sent=True), - ExperienceTest(experience=ScheduleExperience.DEFAULT, offset=-10, email_sent=True), - ExperienceTest(experience=ScheduleExperience.COURSE_UPDATES, offset=-3, email_sent=True), - ExperienceTest(experience=ScheduleExperience.COURSE_UPDATES, offset=-10, email_sent=False), - ) - @patch.object(tasks, 'ace') - def test_experience_type_exclusion(self, test_config, mock_ace): - current_day = _get_datetime_beginning_of_day(datetime.datetime.now(pytz.UTC)) - target_day = current_day + datetime.timedelta(days=test_config.offset) - - schedule = ScheduleFactory.create( - start=target_day, - enrollment__course__self_paced=True, - experience__experience_type=test_config.experience, - ) - - tasks.ScheduleRecurringNudge.apply(kwargs=dict( - site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=test_config.offset, - bin_num=(schedule.enrollment.user.id % tasks.ScheduleRecurringNudge.num_bins), - )) - - self.assertEqual(mock_ace.send.called, test_config.email_sent) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py index 88ac415810..b4f3a5cd64 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_course_update.py @@ -1,3 +1,4 @@ +import ddt from mock import patch from unittest import skipUnless @@ -5,11 +6,16 @@ from django.conf import settings from openedx.core.djangoapps.schedules import resolvers, tasks from openedx.core.djangoapps.schedules.management.commands import send_course_update as nudge -from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase +from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ( + ScheduleSendEmailTestBase, + ExperienceTest +) from openedx.core.djangoapps.schedules.management.commands.tests.upsell_base import ScheduleUpsellTestMixin +from openedx.core.djangoapps.schedules.models import ScheduleExperience from openedx.core.djangolib.testing.utils import skip_unless_lms +@ddt.ddt @skip_unless_lms @skipUnless( 'openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS, @@ -25,7 +31,8 @@ class TestSendCourseUpdate(ScheduleUpsellTestMixin, ScheduleSendEmailTestBase): command = nudge.Command deliver_config = 'deliver_course_update' enqueue_config = 'enqueue_course_update' - expected_offsets = xrange(-7, -77, -7) + expected_offsets = range(-7, -77, -7) + experience_type = ScheduleExperience.EXPERIENCES.course_updates queries_deadline_for_each_course = True @@ -35,3 +42,11 @@ class TestSendCourseUpdate(ScheduleUpsellTestMixin, ScheduleSendEmailTestBase): mock_highlights = patcher.start() mock_highlights.return_value = ['Highlight {}'.format(num + 1) for num in range(3)] self.addCleanup(patcher.stop) + + @ddt.data( + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.default, offset=expected_offsets[0], email_sent=False), + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.course_updates, offset=expected_offsets[0], email_sent=True), + ExperienceTest(experience=None, offset=expected_offsets[0], email_sent=False), + ) + def test_schedule_in_different_experience(self, test_config): + self._check_if_email_sent_for_experience(test_config) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py index 044c4d97d7..6a152cb24d 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_recurring_nudge.py @@ -1,14 +1,18 @@ from unittest import skipUnless +import ddt from django.conf import settings from openedx.core.djangoapps.schedules import resolvers, tasks from openedx.core.djangoapps.schedules.management.commands import send_recurring_nudge as nudge -from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase +from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase, \ + ExperienceTest from openedx.core.djangoapps.schedules.management.commands.tests.upsell_base import ScheduleUpsellTestMixin +from openedx.core.djangoapps.schedules.models import ScheduleExperience from openedx.core.djangolib.testing.utils import skip_unless_lms +@ddt.ddt @skip_unless_lms @skipUnless( 'openedx.core.djangoapps.schedules.apps.SchedulesConfig' in settings.INSTALLED_APPS, @@ -27,3 +31,14 @@ class TestSendRecurringNudge(ScheduleUpsellTestMixin, ScheduleSendEmailTestBase) expected_offsets = (-3, -10) consolidates_emails_for_learner = True + + @ddt.data( + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.default, offset=-3, email_sent=True), + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.default, offset=-10, email_sent=True), + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.course_updates, offset=-3, email_sent=True), + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.course_updates, offset=-10, email_sent=False), + ExperienceTest(experience=None, offset=-3, email_sent=True), + ExperienceTest(experience=None, offset=-10, email_sent=True), + ) + def test_nudge_experience(self, test_config): + self._check_if_email_sent_for_experience(test_config) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py index d323874e2a..6c00d3a97d 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_upgrade_reminder.py @@ -11,8 +11,9 @@ from opaque_keys.edx.locator import CourseLocator from course_modes.models import CourseMode from openedx.core.djangoapps.schedules import resolvers, tasks from openedx.core.djangoapps.schedules.management.commands import send_upgrade_reminder as reminder -from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase -from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory +from openedx.core.djangoapps.schedules.management.commands.tests.send_email_base import ScheduleSendEmailTestBase, \ + ExperienceTest +from openedx.core.djangoapps.schedules.models import ScheduleExperience from openedx.core.djangolib.testing.utils import skip_unless_lms from student.tests.factories import UserFactory @@ -41,18 +42,14 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): @ddt.data(True, False) @patch.object(tasks, 'ace') def test_verified_learner(self, is_verified, mock_ace): - user = UserFactory.create(id=self.task.num_bins) current_day, offset, target_day, upgrade_deadline = self._get_dates() - ScheduleFactory.create( - upgrade_deadline=upgrade_deadline, - enrollment__course__self_paced=True, - enrollment__user=user, + schedule = self._schedule_factory( enrollment__mode=CourseMode.VERIFIED if is_verified else CourseMode.AUDIT, ) self.task.apply(kwargs=dict( site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, - bin_num=self._calculate_bin_for_user(user), + bin_num=self._calculate_bin_for_user(schedule.enrollment.user), )) self.assertEqual(mock_ace.send.called, not is_verified) @@ -62,10 +59,8 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): user = UserFactory.create() schedules = [ - ScheduleFactory.create( - upgrade_deadline=upgrade_deadline, + self._schedule_factory( enrollment__user=user, - enrollment__course__self_paced=True, enrollment__course__id=CourseLocator('edX', 'toy', 'Course{}'.format(i)), enrollment__mode=CourseMode.VERIFIED if i in (0, 3) else CourseMode.AUDIT, ) @@ -88,3 +83,23 @@ class TestUpgradeReminder(ScheduleSendEmailTestBase): message.context['course_ids'], [str(schedules[i].enrollment.course.id) for i in (1, 2, 4)] ) + + @patch.object(tasks, 'ace') + def test_course_without_verified_mode(self, mock_ace): + current_day, offset, target_day, upgrade_deadline = self._get_dates() + schedule = self._schedule_factory() + schedule.enrollment.course.modes.filter(mode_slug=CourseMode.VERIFIED).delete() + + self.task.apply(kwargs=dict( + site_id=self.site_config.site.id, target_day_str=serialize(target_day), day_offset=offset, + bin_num=self._calculate_bin_for_user(schedule.enrollment.user), + )) + self.assertEqual(mock_ace.send.called, False) + + @ddt.data( + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.default, offset=expected_offsets[0], email_sent=True), + ExperienceTest(experience=ScheduleExperience.EXPERIENCES.course_updates, offset=expected_offsets[0], email_sent=False), + ExperienceTest(experience=None, offset=expected_offsets[0], email_sent=True), + ) + def test_upgrade_reminder_experience(self, test_config): + self._check_if_email_sent_for_experience(test_config) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py index ade7ce9fd3..229227142a 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/upsell_base.py @@ -8,7 +8,6 @@ from edx_ace.utils.date import serialize from edx_ace.message import Message from courseware.models import DynamicUpgradeDeadlineConfiguration -from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory @ddt.ddt @@ -34,10 +33,8 @@ class ScheduleUpsellTestMixin(object): if testcase.set_deadline: upgrade_deadline = current_day + datetime.timedelta(days=testcase.deadline_offset) - schedule = ScheduleFactory.create( - start=target_day, - upgrade_deadline=upgrade_deadline, - enrollment__course__self_paced=True, + schedule = self._schedule_factory( + upgrade_deadline=upgrade_deadline ) sent_messages = [] diff --git a/openedx/core/djangoapps/schedules/models.py b/openedx/core/djangoapps/schedules/models.py index adf6cbd454..b60ba955fc 100644 --- a/openedx/core/djangoapps/schedules/models.py +++ b/openedx/core/djangoapps/schedules/models.py @@ -1,6 +1,7 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from django.contrib.sites.models import Site +from model_utils import Choices from model_utils.models import TimeStampedModel from config_models.models import ConfigurationModel @@ -27,7 +28,7 @@ class Schedule(TimeStampedModel): try: return self.experience.experience_type except ScheduleExperience.DoesNotExist: - return ScheduleExperience.DEFAULT + return ScheduleExperience.EXPERIENCES.default class Meta(object): verbose_name = _('Schedule') @@ -48,12 +49,10 @@ class ScheduleConfig(ConfigurationModel): class ScheduleExperience(models.Model): - DEFAULT = 0 - COURSE_UPDATES = 1 - EXPERIENCES = ( - (DEFAULT, 'Recurring Nudge and Upgrade Reminder'), - (COURSE_UPDATES, 'Course Updates') + EXPERIENCES = Choices( + (0, 'default', 'Recurring Nudge and Upgrade Reminder'), + (1, 'course_updates', 'Course Updates') ) schedule = models.OneToOneField(Schedule, related_name='experience') - experience_type = models.PositiveSmallIntegerField(choices=EXPERIENCES, default=DEFAULT) + experience_type = models.PositiveSmallIntegerField(choices=EXPERIENCES, default=EXPERIENCES.default) diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 05ba2b4b6b..d3fcf91534 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -77,7 +77,8 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): schedule_date_field = None num_bins = DEFAULT_NUM_BINS - experience_filter = Q(experience__experience_type=ScheduleExperience.DEFAULT) | Q(experience__isnull=True) + experience_filter = (Q(experience__experience_type=ScheduleExperience.EXPERIENCES.default) + | Q(experience__isnull=True)) def __attrs_post_init__(self): # TODO: in the next refactor of this task, pass in current_datetime instead of reproducing it here @@ -126,8 +127,6 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): schedules = Schedule.objects.select_related( 'enrollment__user__profile', 'enrollment__course', - ).prefetch_related( - 'enrollment__course__modes', ).filter( Q(enrollment__course__end__isnull=True) | Q( enrollment__course__end__gte=self.current_datetime), @@ -148,6 +147,8 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): # This will run the query and cache all of the results in memory. num_schedules = len(schedules) + LOG.debug('Number of schedules = %d', num_schedules) + # This should give us a sense of the volume of data being processed by each task. set_custom_metric('num_schedules', num_schedules) @@ -240,10 +241,10 @@ class RecurringNudgeResolver(BinnedSchedulesBaseResolver): @property def experience_filter(self): if self.day_offset == -3: - experiences = [ScheduleExperience.DEFAULT, ScheduleExperience.COURSE_UPDATES] + experiences = [ScheduleExperience.EXPERIENCES.default, ScheduleExperience.EXPERIENCES.course_updates] return Q(experience__experience_type__in=experiences) | Q(experience__isnull=True) else: - return Q(experience__experience_type=ScheduleExperience.DEFAULT) | Q(experience__isnull=True) + return Q(experience__experience_type=ScheduleExperience.EXPERIENCES.default) | Q(experience__isnull=True) def get_template_context(self, user, user_schedules): first_schedule = user_schedules[0] @@ -346,7 +347,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver): log_prefix = 'Course Update' schedule_date_field = 'start' num_bins = COURSE_UPDATE_NUM_BINS - experience_filter = Q(experience__experience_type=ScheduleExperience.COURSE_UPDATES) + experience_filter = Q(experience__experience_type=ScheduleExperience.EXPERIENCES.course_updates) def schedules_for_bin(self): week_num = abs(self.day_offset) / 7 diff --git a/openedx/core/djangoapps/schedules/signals.py b/openedx/core/djangoapps/schedules/signals.py index 45b5927c7f..9fba0ae534 100644 --- a/openedx/core/djangoapps/schedules/signals.py +++ b/openedx/core/djangoapps/schedules/signals.py @@ -64,14 +64,14 @@ def create_schedule(sender, **kwargs): try: get_week_highlights(enrollment.course_id, 1) - experience_type = ScheduleExperience.COURSE_UPDATES + experience_type = ScheduleExperience.EXPERIENCES.course_updates except CourseUpdateDoesNotExist: - experience_type = ScheduleExperience.DEFAULT + experience_type = ScheduleExperience.EXPERIENCES.default ScheduleExperience(schedule=schedule, experience_type=experience_type).save() log.debug('Schedules: created a new schedule starting at %s with an upgrade deadline of %s and experience type: %s', - content_availability_date, upgrade_deadline, ScheduleExperience.EXPERIENCES[experience_type][1]) + content_availability_date, upgrade_deadline, ScheduleExperience.EXPERIENCES[experience_type]) @receiver(COURSE_START_DATE_CHANGED, dispatch_uid="update_schedules_on_course_start_changed") diff --git a/openedx/core/djangoapps/schedules/tests/factories.py b/openedx/core/djangoapps/schedules/tests/factories.py index af6a5875c9..4b54c712f2 100644 --- a/openedx/core/djangoapps/schedules/tests/factories.py +++ b/openedx/core/djangoapps/schedules/tests/factories.py @@ -10,7 +10,7 @@ class ScheduleExperienceFactory(factory.DjangoModelFactory): class Meta(object): model = models.ScheduleExperience - experience_type = models.ScheduleExperience.DEFAULT + experience_type = models.ScheduleExperience.EXPERIENCES.default class ScheduleFactory(factory.DjangoModelFactory): diff --git a/openedx/core/djangoapps/schedules/tests/test_signals.py b/openedx/core/djangoapps/schedules/tests/test_signals.py index 98b6f48f67..f89bd8a508 100644 --- a/openedx/core/djangoapps/schedules/tests/test_signals.py +++ b/openedx/core/djangoapps/schedules/tests/test_signals.py @@ -6,6 +6,7 @@ from pytz import utc from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from courseware.models import DynamicUpgradeDeadlineConfiguration +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.schedules.models import ScheduleExperience from openedx.core.djangoapps.schedules.signals import CREATE_SCHEDULE_WAFFLE_FLAG from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory @@ -24,16 +25,22 @@ from ..tests.factories import ScheduleConfigFactory @skip_unless_lms class CreateScheduleTests(SharedModuleStoreTestCase): - def assert_schedule_created(self, experience_type=ScheduleExperience.DEFAULT): + def assert_schedule_created(self, experience_type=ScheduleExperience.EXPERIENCES.default): course = _create_course_run(self_paced=True) - enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT) + enrollment = CourseEnrollmentFactory( + course_id=course.id, + mode=CourseMode.AUDIT, + ) self.assertIsNotNone(enrollment.schedule) self.assertIsNone(enrollment.schedule.upgrade_deadline) self.assertEquals(enrollment.schedule.experience.experience_type, experience_type) def assert_schedule_not_created(self): course = _create_course_run(self_paced=True) - enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT) + enrollment = CourseEnrollmentFactory( + course_id=course.id, + mode=CourseMode.AUDIT, + ) with self.assertRaises(Schedule.DoesNotExist): enrollment.schedule @@ -86,7 +93,7 @@ class CreateScheduleTests(SharedModuleStoreTestCase): site = SiteFactory.create() mock_get_week_highlights.return_value = True mock_get_current_site.return_value = site - self.assert_schedule_created(experience_type=ScheduleExperience.COURSE_UPDATES) + self.assert_schedule_created(experience_type=ScheduleExperience.EXPERIENCES.course_updates) @ddt.ddt @@ -114,7 +121,7 @@ class UpdateScheduleTests(SharedModuleStoreTestCase): course = _create_course_run(self_paced=True, start_day_offset=5) # course starts in future enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT) - self.assert_schedule_dates(enrollment.schedule, enrollment.course_overview.start) + self.assert_schedule_dates(enrollment.schedule, enrollment.course.start) course.start = course.start + datetime.timedelta(days=3) # new course start changes to another future date self.store.update_item(course, ModuleStoreEnum.UserID.test) @@ -138,7 +145,7 @@ class UpdateScheduleTests(SharedModuleStoreTestCase): course = _create_course_run(self_paced=True, start_day_offset=5) # course starts in future enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT) - previous_start = enrollment.course_overview.start + previous_start = enrollment.course.start self.assert_schedule_dates(enrollment.schedule, previous_start) course.start = course.start + datetime.timedelta(days=-10) # new course start changes to a past date From 1ba564c32e5b0411e9c566bccb21a1d24317d922 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Wed, 1 Nov 2017 14:33:48 -0400 Subject: [PATCH 37/47] Edit highlights modal copy Make modal intro text darker. Make intro message dark in the base modal scss --- .../js/views/modals/course_outline_modals.js | 8 ++-- cms/static/sass/elements/_modal-window.scss | 2 +- cms/static/sass/views/_outline.scss | 21 +++++------ cms/templates/js/highlights-editor.underscore | 37 ++++++++++++------- 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index 3711e0d78f..9c4603e092 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -225,11 +225,9 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', getIntroductionMessage: function() { return StringUtils.interpolate( gettext( - 'The highlights you provide here are messaged (i.e., emailed) to learners. Each {item}\'s ' + - 'highlights are emailed at the time that we expect the learner to start working on that {item}. ' + - 'At this time, we assume that each {item} will take 1 week to complete.' - ), - {item: this.options.xblockType} + 'Enter 3-5 highlights to include in the email message that learners receive for ' + + 'this section (250 character limit).' + ) ); }, diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 20afee0133..097a45a74f 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -41,7 +41,7 @@ @extend %t-copy-sub1; margin: 0 0 $baseline 0; - color: $gray; + color: $gray-d2; } .message-status { diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index ab5880b39b..605d5ecc50 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -654,15 +654,16 @@ width: 18px; } - .highlight-input-text { - width: 100%; - margin-bottom: ($baseline/4); - margin-top: ($baseline/4); - } + .highlights-section-modal { + .highlight-input-text { + width: 100%; + margin-bottom: ($baseline/4); + margin-top: ($baseline/4); + } - .highlights-description { - font-size: 80%; - font-weight: bolder; + .highlight-input-label { + font-weight: 600; + } } // outline: edit item settings @@ -754,10 +755,6 @@ .bulkpublish-section-modal, .bulkpublish-subsection-modal, .bulkpublish-unit-modal { - .modal-introduction { - color: $gray-d2; - } - .modal-section .outline-bulkpublish { max-height: ($baseline*20); overflow-y: auto; diff --git a/cms/templates/js/highlights-editor.underscore b/cms/templates/js/highlights-editor.underscore index a8c2e97b88..ca398f614c 100644 --- a/cms/templates/js/highlights-editor.underscore +++ b/cms/templates/js/highlights-editor.underscore @@ -2,25 +2,34 @@ From e85af5d87b5fa9ea00a149836b0dd7c5c234deda Mon Sep 17 00:00:00 2001 From: John Eskew Date: Wed, 1 Nov 2017 16:55:02 -0400 Subject: [PATCH 38/47] Point to the proper AppConfig in INSTALLED_APPS for course_modes. --- cms/envs/common.py | 2 +- lms/envs/common.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index fe5b913f61..fd5c124322 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -988,7 +988,7 @@ INSTALLED_APPS = [ 'django.contrib.admin', # for managing course modes - 'course_modes', + 'course_modes.apps.CourseModesConfig', # Verified Track Content Cohorting (Beta feature that will hopefully be removed) 'openedx.core.djangoapps.verified_track_content', diff --git a/lms/envs/common.py b/lms/envs/common.py index ab51b1b5ea..a49027076a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2159,7 +2159,7 @@ INSTALLED_APPS = [ 'notifier_api', # Different Course Modes - 'course_modes', + 'course_modes.apps.CourseModesConfig', # Enrollment API 'enrollment', From 268bea9e6244cf013334e0d3d8b41124f06e481c Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Wed, 18 Oct 2017 16:03:41 -0400 Subject: [PATCH 39/47] Make the course content page responsive LEARNER-2754 --- .stylelintignore | 1 + cms/static/sass/views/_certificates.scss | 43 ++++-- cms/static/sass/views/_container.scss | 3 +- .../sass/views/_group-configuration.scss | 3 +- cms/static/sass/views/_outline.scss | 3 +- .../xmodule/css/annotatable/display.scss | 16 +- .../lib/xmodule/xmodule/css/capa/display.scss | 34 ++--- .../lib/xmodule/xmodule/css/html/display.scss | 67 +++++---- .../lib/xmodule/xmodule/css/poll/display.scss | 8 +- .../lib/xmodule/xmodule/css/problem/edit.scss | 4 +- .../xmodule/xmodule/css/sequence/display.scss | 91 +++++++---- .../xmodule/xmodule/css/tabs/codemirror.scss | 2 +- common/lib/xmodule/xmodule/css/tabs/tabs.scss | 11 +- .../xmodule/css/video/accessible_menu.scss | 26 ++-- .../xmodule/xmodule/css/video/display.scss | 53 +++---- common/static/sass/_mixins-inherited.scss | 10 +- common/static/sass/_mixins.scss | 51 ++++--- .../static/sass/bourbon/addons/_button.scss | 2 +- .../sass/bourbon/functions/_tint-shade.scss | 4 +- .../_breadcrumbs.scss | 16 ++ lms/static/sass/_experiments.scss | 3 +- lms/static/sass/_variables.scss | 2 + lms/static/sass/base/_base.scss | 10 +- lms/static/sass/base/_layouts.scss | 11 +- lms/static/sass/base/_mixins.scss | 6 +- lms/static/sass/bootstrap/_layouts.scss | 26 +++- lms/static/sass/course/_info.scss | 6 +- lms/static/sass/course/base/_extends.scss | 17 ++- lms/static/sass/course/base/_mixins.scss | 9 +- .../sass/course/courseware/_courseware.scss | 12 +- .../sass/course/instructor/_instructor_2.scss | 141 +++++++++++------- .../course/layout/_courseware_header.scss | 28 ++-- .../course/layout/_courseware_preview.scss | 2 +- lms/static/sass/course/wiki/_basic-html.scss | 95 ++++++++++-- lms/static/sass/course/wiki/_create.scss | 5 +- .../utilities/_v1-compatibility.scss | 4 +- lms/static/sass/features/_bookmarks-v1.scss | 2 + lms/static/sass/multicourse/_about_pages.scss | 29 ++-- lms/static/sass/multicourse/_account.scss | 16 +- lms/static/sass/multicourse/_courses.scss | 29 ++-- lms/static/sass/multicourse/_help.scss | 9 +- lms/static/sass/multicourse/_home.scss | 32 ++-- lms/static/sass/shared-v2/_layouts.scss | 31 ++-- lms/static/sass/views/_verification.scss | 53 ++++--- .../courseware/course_navigation.html | 2 +- lms/templates/seq_module.html | 10 +- .../reference/bootstrap/course-skeleton.html | 2 +- .../ux/reference/bootstrap/unit-page.html | 2 +- package.json | 2 +- scripts/all-tests.sh | 2 +- 50 files changed, 650 insertions(+), 396 deletions(-) diff --git a/.stylelintignore b/.stylelintignore index e35dcf12ef..d30a987fcd 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,4 +1,5 @@ common/lib/xmodule/xmodule/css common/static/sass/bourbon common/static/xmodule/modules/css +common/test/test-theme lms/static/sass/vendor diff --git a/cms/static/sass/views/_certificates.scss b/cms/static/sass/views/_certificates.scss index 6ae672652c..124840a7af 100644 --- a/cms/static/sass/views/_certificates.scss +++ b/cms/static/sass/views/_certificates.scss @@ -11,7 +11,8 @@ // * +Layout - Certificates // ==================== .view-certificates { - .content-primary, .content-supplementary { + .content-primary, + .content-supplementary { @include box-sizing(border-box); float: left; @@ -66,10 +67,11 @@ width: flex-grid(3, 12); } - .certificate-info-section{ + .certificate-info-section { overflow: auto; - .course-title-section, .course-number-section{ + .course-title-section, + .course-number-section { min-width: 47%; @include margin-right(2%); @@ -150,7 +152,7 @@ .collection-details { .actions { - @include transition(opacity .15s .25s ease-in-out); + @include transition(opacity 0.15s 0.25s ease-in-out); position: absolute; top: $baseline; @@ -285,7 +287,9 @@ } } - label, input, textarea { + label, + input, + textarea { display: block; } @@ -306,7 +310,8 @@ } //this section is borrowed from _account.scss - we should clean up and unify later - input, textarea { + input, + textarea { @extend %t-copy-base; height: 100%; @@ -491,7 +496,8 @@ } .view-certificates .certificates { - .certificate-details, .certificate-edit { + .certificate-details, + .certificate-edit { .title { @extend %t-title4; @extend %t-strong; @@ -563,7 +569,8 @@ // ==================== // TO-DO: refactor to use collection styling where possible. .view-certificates .certificates { - .signatory-details, .signatory-edit { + .signatory-details, + .signatory-edit { @extend %ui-window; border-color: $gray-l4; @@ -595,7 +602,8 @@ } .signatory-panel-edit { - float:right; + @include float(right); + padding: 8px; position: inherit; } @@ -604,9 +612,11 @@ .signatory-edit { // TO-DO: remove icon styling, use save / cancel pattern for Studio - .signatory-panel-close, .signatory-panel-save, .signatory-panel-delete { - float:right; - padding:10px; + .signatory-panel-close, + .signatory-panel-save, + .signatory-panel-delete { + float: right; + padding: $baseline/2; } .tip { @@ -637,7 +647,9 @@ } } - label, input, textarea { + label, + input, + textarea { display: block; } @@ -658,7 +670,8 @@ } //TO-DO: this section is borrowed from _account.scss - we should clean up and unify later - input, textarea { + input, + textarea { @extend %t-copy-base; height: 100%; @@ -705,7 +718,7 @@ border-color: $red; } - .message-error{ + .message-error { color: $red; } } diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index ae8c0efe9c..197453a746 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -427,7 +427,8 @@ } } - &:hover, &:focus { + &:hover, + &:focus { background: $color-background-alternate; } } diff --git a/cms/static/sass/views/_group-configuration.scss b/cms/static/sass/views/_group-configuration.scss index ba89d4aa98..d9bb1b81aa 100644 --- a/cms/static/sass/views/_group-configuration.scss +++ b/cms/static/sass/views/_group-configuration.scss @@ -75,7 +75,8 @@ display: inline-block; color: $black; - &:hover, &:focus { + &:hover, + &:focus { color: $blue; } diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index ab5880b39b..7a00555e09 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -48,7 +48,8 @@ } // STATE: hover/focus - &:hover, &:focus { + &:hover, + &:focus { .incontext-editor-open-action { opacity: 1; } diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 8a58c6b429..f35a160356 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -12,12 +12,12 @@ $annotatable--body-font-size: em(14); } .annotatable-header { - margin-bottom: .5em; + margin-bottom: 0.5em; } .annotatable-section { position: relative; - padding: .5em 1em; + padding: 0.5em 1em; border: 1px solid $annotatable--border-color; border-radius: 0.5em; margin-bottom: 0.5em; @@ -55,8 +55,8 @@ $annotatable--body-font-size: em(14); position: absolute; right: 0; margin: 2px 1em 2px 0; - &.expanded:after { content: " \2191" } - &.collapsed:after { content: " \2193" } + &.expanded::after { content: " \2191"; } + &.collapsed::after { content: " \2193"; } } .annotatable-span { @@ -75,9 +75,9 @@ $annotatable--body-font-size: em(14); (purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) { $highlight_index: $highlight_index + 1; - $marker: nth($highlight,1); - $color: nth($highlight,2); - $selected_color: nth($highlight,3); + $marker: nth($highlight, 1); + $color: nth($highlight, 2); + $selected_color: nth($highlight, 3); @if $highlight_index == 1 { &.highlight { @@ -177,7 +177,7 @@ $annotatable--body-font-size: em(14); } } - &:after { + &::after { content: ''; display: inline-block; position: absolute; diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 6b2e181876..6f2373b051 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -21,8 +21,8 @@ // +Variables - Capa // ==================== -$annotation-yellow: rgba(255, 255,10, 0.3); -$color-copy-tip: rgb(100,100,100); +$annotation-yellow: rgba(255, 255, 10, 0.3); +$color-copy-tip: rgb(100, 100, 100); // FontAwesome Icon code // ==================== @@ -45,9 +45,9 @@ $asterisk-icon: '\f069'; // .fa-asterisk // +Mixins - Status Icon - Capa // ==================== -@mixin status-icon($color: $gray, $fontAwesomeIcon: "\f00d"){ +@mixin status-icon($color: $gray, $fontAwesomeIcon: "\f00d") { .status-icon { - &:after { + &::after { @extend %use-font-awesome; color: $color; @@ -219,7 +219,7 @@ div.problem { padding: ($baseline/2); width: 100%; - &:after { + &::after { @include margin-left($baseline*0.75); } @@ -365,7 +365,7 @@ div.problem { div.problem { ol.enumerate { li { - &:before { + &::before { display: block; visibility: hidden; height: 0; @@ -456,7 +456,7 @@ div.problem { margin-top: ($baseline / 2); margin-bottom: 0; - &:before { + &::before { @extend %t-strong; display: inline; @@ -465,7 +465,7 @@ div.problem { } &:empty { - &:before { + &::before { display: none; } } @@ -845,7 +845,7 @@ div.problem { .status { .status-icon { - &:after { + &::after { content: ''; } } @@ -871,11 +871,11 @@ div.problem { .indicator-container { display: inline-block; - .status.correct:after, - .status.partially-correct:after, - .status.incorrect:after, - .status.submitted:after, - .status.unanswered:after { + .status.correct::after, + .status.partially-correct::after, + .status.incorrect::after, + .status.submitted::after, + .status.unanswered::after { @include margin-left(0); } } @@ -1485,7 +1485,7 @@ div.problem { font-weight: normal; } - a.annotation-return:after { content: " \2191" } + a.annotation-return::after { content: " \2191" } .block, ul.tags { margin: .5em 0; @@ -1557,7 +1557,7 @@ div.problem { pre { background-color: $gray-l3; color: $black; } - &:before { + &::before { @extend %t-strong; display: block; @@ -1603,7 +1603,7 @@ div.problem { } label.choicetextgroup_show_correct, section.choicetextgroup_show_correct { - &:after { + &::after { @include margin-left($baseline*0.75); content: url('#{$static-path}/images/correct-icon.png'); diff --git a/common/lib/xmodule/xmodule/css/html/display.scss b/common/lib/xmodule/xmodule/css/html/display.scss index a7f5da906a..d16c2e7497 100644 --- a/common/lib/xmodule/xmodule/css/html/display.scss +++ b/common/lib/xmodule/xmodule/css/html/display.scss @@ -19,7 +19,10 @@ h2 { -webkit-font-smoothing: antialiased; } -h3, h4, h5, h6 { +h3, +h4, +h5, +h6 { @include margin(0, 0, ($baseline/2), 0); font-weight: 600; @@ -34,7 +37,7 @@ h4 { } h5 { - font-size: .83em; + font-size: 0.83em; } h6 { @@ -48,7 +51,8 @@ p { color: $body-color; } -em, i { +em, +i { font-style: italic; span { @@ -56,7 +60,8 @@ em, i { } } -strong, b { +strong, +b { font-weight: bold; span { @@ -64,7 +69,9 @@ strong, b { } } -p + p, ul + p, ol + p { +p + p, +ul + p, +ol + p { margin-top: $baseline; } @@ -72,7 +79,8 @@ blockquote { margin: 1em ($baseline*2); } -ol, ul { +ol, +ul { // Using the lower level Bi App Sass mixin to avoid @padding conflicts with bourbon. @include bi-app-compact(padding, 0, 0, 0, 1em); @@ -93,7 +101,11 @@ ul { } a { - &:link, &:visited, &:hover, &:active, &:focus { + &:link, + &:visited, + &:hover, + &:active, + &:focus { color: $blue; } } @@ -124,7 +136,8 @@ table { border-collapse: collapse; font-size: 16px; - td, th { + td, + th { margin: $baseline 0; padding: ($baseline/2); border: 1px solid $gray-l3; @@ -162,37 +175,37 @@ th { display: block; padding: ($baseline/4) 7px; border-radius: 5px; - opacity: .9; + opacity: 0.9; background: $white; color: $black; border: 2px solid $black; - + .label { font-weight: bold; } - + i { font-style: normal; } } - + .image-link { @extend %ui-fake-link; position: relative; display: block; - + .action-fullscreen { display: none; top: 10px; left: 10px; } - + &:hover .action-fullscreen { display: block; } } - + .image-modal { @extend %ui-fake-link; @extend %ui-depth5; @@ -204,7 +217,7 @@ th { height: 100%; width: 100%; background-color: rgba(0, 0, 0, 0.7); - + .image-content { position: relative; top: 2.5%; @@ -213,10 +226,10 @@ th { width: 95%; margin: auto; overflow: hidden; - + .image-wrapper { position: relative; - + img { position: relative; display: block; @@ -226,12 +239,12 @@ th { cursor: default; } } - + .action-close { top: 10px; right: 10px; } - + .image-controls { position: absolute; right: 10px; @@ -239,16 +252,16 @@ th { margin: 0; padding: 0; list-style: none; - + .image-control { position: relative; display: inline-block; margin: 0; padding: 0; - + .modal-ui-icon { position: relative; - + &.action-zoom-in { margin-right: ($baseline/4); } @@ -265,17 +278,17 @@ th { } } } - + &.image-is-fit-to-screen { display: block; - + // !important used here to override jQuery. .image-content .image-wrapper { top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; - + img { top: 0 !important; left: 0 !important; @@ -285,7 +298,7 @@ th { &.image-is-zoomed { display: block; - + .image-content .image-wrapper { img { max-width: none; diff --git a/common/lib/xmodule/xmodule/css/poll/display.scss b/common/lib/xmodule/xmodule/css/poll/display.scss index fd15f8c49d..cf46fcf3bf 100644 --- a/common/lib/xmodule/xmodule/css/poll/display.scss +++ b/common/lib/xmodule/xmodule/css/poll/display.scss @@ -179,7 +179,7 @@ div.poll_question { .percent { background-color: gray; - width: 0px; + width: 0; height: 20px; &.short { } @@ -202,16 +202,16 @@ div.poll_question { } .poll_answer.answered { - -webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + -webkit-box-shadow: rgb(97, 184, 225) 0 1px 0 0 inset; background-color: rgb(29, 157, 217); background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176)); border-bottom-color: rgb(13, 114, 162); border-left-color: rgb(13, 114, 162); border-right-color: rgb(13, 114, 162); border-top-color: rgb(13, 114, 162); - box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + box-shadow: rgb(97, 184, 225) 0 1px 0 0 inset; color: rgb(255, 255, 255); - text-shadow: rgb(7, 103, 148) 0px 1px 0px; + text-shadow: rgb(7, 103, 148) 0 1px 0; } .button.reset-button { diff --git a/common/lib/xmodule/xmodule/css/problem/edit.scss b/common/lib/xmodule/xmodule/css/problem/edit.scss index 74b518ac5a..e0a43c5fb2 100644 --- a/common/lib/xmodule/xmodule/css/problem/edit.scss +++ b/common/lib/xmodule/xmodule/css/problem/edit.scss @@ -51,7 +51,7 @@ background-color: $white; overflow: hidden; - @include transition(width .3s linear 0s); + @include transition(width 0.3s linear 0s); &.shown { width: 20%; @@ -108,7 +108,7 @@ .problem-editor { // adding padding to simple editor only - adjacent selector is needed since there are no toggles for CodeMirror - .markdown-box+.CodeMirror { + .markdown-box + .CodeMirror { padding: 10px; } } diff --git a/common/lib/xmodule/xmodule/css/sequence/display.scss b/common/lib/xmodule/xmodule/css/sequence/display.scss index 4448001ed3..2d2712eff7 100644 --- a/common/lib/xmodule/xmodule/css/sequence/display.scss +++ b/common/lib/xmodule/xmodule/css/sequence/display.scss @@ -12,7 +12,8 @@ $seq-nav-height: 44px; display: block; - &:hover, &:focus { + &:hover, + &:focus { background: none; } } @@ -32,14 +33,15 @@ $seq-nav-height: 44px; display: block; - &:hover, &:focus { + &:hover, + &:focus { background: none; } } } } -%ui-clear-button { +%ui-clear-button { background-color: transparent; background-image: none; background-position: center 14px; @@ -60,16 +62,13 @@ $seq-nav-height: 44px; .sequence-nav { @extend .topbar; - margin: 0 0 $baseline 0; + margin: 0 auto $baseline; position: relative; border-bottom: none; z-index: 0; height: $seq-nav-height; display: flex; - - .sequence-nav-button { - max-width: 200px; - } + justify-content: center; @media print { display: none; @@ -81,6 +80,11 @@ $seq-nav-height: 44px; position: relative; height: 100%; flex-grow: 1; + + @include media-breakpoint-down(md) { + white-space: nowrap; + overflow-x: scroll; + } } ol { @@ -88,7 +92,7 @@ $seq-nav-height: 44px; li { box-sizing: border-box; - min-width: 20px; + min-width: 40px; flex-grow: 1; border-color: $seq-nav-border-color; border-width: 1px; @@ -127,28 +131,28 @@ $seq-nav-height: 44px; //video &.seq_video { - .icon:before { + .icon::before { content: "\f008"; // .fa-film } } //other &.seq_other { - .icon:before { + .icon::before { content: "\f02d"; // .fa-book } } //vertical &.seq_vertical { - .icon:before { + .icon::before { content: "\f00b"; // .fa-tasks } } //problems &.seq_problem { - .icon:before { + .icon::before { content: "\f044"; // .fa-pencil-square-o } } @@ -207,38 +211,60 @@ $seq-nav-height: 44px; display: block; top: 0; + min-width: 40px; + max-width: 40px; height: 100%; text-shadow: none; // overrides default button text-shadow background: none; // overrides default button gradient - background-color: white; + background-color: theme-color("inverse"); border-color: $seq-nav-border-color; box-shadow: none; - min-width: 120px; font-size: inherit; font-weight: normal; - padding: 0 $baseline; - text-overflow: ellipsis; + padding: 0; white-space: nowrap; - overflow: hidden; + overflow-x: scroll; - span:not(:last-child) { - @include padding-right($baseline / 2); + @include media-breakpoint-up(md) { + min-width: 120px; + max-width: 200px; + text-overflow: ellipsis; + + span:not(:last-child) { + @include padding-right($baseline / 2); + } + } + + .sequence-nav-button-label { + display: none; + + @include media-breakpoint-up(md) { + display: inline; + } } &.button-previous { - @include left(0); - @include border-top-left-radius(3px); - @include border-top-right-radius(0); - @include border-bottom-right-radius(0); - @include border-bottom-left-radius(3px); + order: -999; + + @include media-breakpoint-up(md) { + @include left(0); + @include border-top-left-radius(3px); + @include border-top-right-radius(0); + @include border-bottom-right-radius(0); + @include border-bottom-left-radius(3px); + } } &.button-next { - @include right(0); - @include border-top-left-radius(0); - @include border-top-right-radius(3px); - @include border-bottom-right-radius(3px); - @include border-bottom-left-radius(0); + order: 999; + + @include media-breakpoint-up(md) { + @include right(0); + @include border-top-left-radius(0); + @include border-top-right-radius(3px); + @include border-bottom-right-radius(3px); + @include border-bottom-left-radius(0); + } } &.disabled { @@ -250,7 +276,7 @@ $seq-nav-height: 44px; display: none; } -nav.sequence-bottom { +.sequence-bottom { position: relative; height: 45px; margin: lh(2) auto; @@ -259,6 +285,9 @@ nav.sequence-bottom { .sequence-nav-button { position: relative; + min-width: 120px; + max-width: 200px; + text-overflow: ellipsis; &:last-of-type { @include border-left(none); diff --git a/common/lib/xmodule/xmodule/css/tabs/codemirror.scss b/common/lib/xmodule/xmodule/css/tabs/codemirror.scss index 4db2b33863..9678958b2b 100644 --- a/common/lib/xmodule/xmodule/css/tabs/codemirror.scss +++ b/common/lib/xmodule/xmodule/css/tabs/codemirror.scss @@ -1,4 +1,4 @@ -.editor{ +.editor { @include clearfix(); .CodeMirror { diff --git a/common/lib/xmodule/xmodule/css/tabs/tabs.scss b/common/lib/xmodule/xmodule/css/tabs/tabs.scss index 20b71ce4b9..f49e65e264 100644 --- a/common/lib/xmodule/xmodule/css/tabs/tabs.scss +++ b/common/lib/xmodule/xmodule/css/tabs/tabs.scss @@ -1,7 +1,7 @@ // styles duped from _unit.scss - Edit Header (Component Name, Mode-Editor, Mode-Settings) -.tabs-wrapper{ +.tabs-wrapper { padding-top: 0; position: relative; @@ -65,7 +65,7 @@ a.tab { @include font-size(14); - @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0)); + @include linear-gradient(top, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); border: 1px solid $blue-d1; border-radius: 3px; @@ -83,7 +83,8 @@ cursor: default; } - &:hover, &:focus { + &:hover, + &:focus { box-shadow: inset 0 1px 2px 1px $shadow; background-image: linear-gradient(#009fe6, #009fe6) !important; } @@ -106,7 +107,7 @@ .comp-subtitles-import-list { > li { display: block; - margin: $baseline/2 0px $baseline/2 0; + margin: $baseline/2 0; } .blue-button { @@ -118,8 +119,6 @@ } } } - - } .component-tab { diff --git a/common/lib/xmodule/xmodule/css/video/accessible_menu.scss b/common/lib/xmodule/xmodule/css/video/accessible_menu.scss index 8df40efdb6..c9b56604c9 100644 --- a/common/lib/xmodule/xmodule/css/video/accessible_menu.scss +++ b/common/lib/xmodule/xmodule/css/video/accessible_menu.scss @@ -1,9 +1,9 @@ $a11y--gray: rgb(127, 127, 127); $a11y--blue: rgb(0, 159, 230); -$a11y--gray-d1: shade($gray,20%); -$a11y--gray-l2: tint($gray,40%); -$a11y--gray-l3: tint($gray,60%); -$a11y--blue-s1: saturate($blue,15%); +$a11y--gray-d1: shade($gray, 20%); +$a11y--gray-l2: tint($gray, 40%); +$a11y--gray-l3: tint($gray, 60%); +$a11y--blue-s1: saturate($blue, 15%); %use-font-awesome { font-family: FontAwesome; @@ -50,12 +50,13 @@ $a11y--blue-s1: saturate($blue,15%); font-size: 14px; line-height: 23px; - &:hover, &:focus { + &:hover, + &:focus { color: $a11y--gray-d1; } } - &.active{ + &.active { a { color: $a11y--blue; } @@ -84,11 +85,10 @@ $a11y--blue-s1: saturate($blue,15%); background-color: $action-primary-active-bg; color: $very-light-text; - &:after { + &::after { color: $very-light-text; } } - } > a { @@ -106,7 +106,7 @@ $a11y--blue-s1: saturate($blue,15%); overflow: hidden; text-overflow: ellipsis; - &:after { + &::after { @extend %use-font-awesome; content: "\f0d7"; @@ -137,7 +137,8 @@ $a11y--blue-s1: saturate($blue,15%); } -.contextmenu, .submenu { +.contextmenu, +.submenu { @extend %ui-depth5; border: 1px solid #333; @@ -157,7 +158,8 @@ $a11y--blue-s1: saturate($blue,15%); display: block; } - .menu-item, .submenu-item { + .menu-item, + .submenu-item { border-top: 1px solid $gray-l3; padding: ($baseline/4) ($baseline/2); outline: none; @@ -184,7 +186,7 @@ $a11y--blue-s1: saturate($blue,15%); position: relative; padding: ($baseline/4) $baseline ($baseline/4) ($baseline/2); - &:after { + &::after { content: '\25B6'; position: absolute; right: 5px; diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index bd6ed2fa4f..1432447709 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -57,7 +57,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .video-player { position: relative; - &:before { + &::before { display: block; content: ""; width: 100%; @@ -75,16 +75,18 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .focus_grabber { position: relative; display: inline; - width: 0px; - height: 0px; + width: 0; + height: 0; } .downloads-heading { - margin: 1em 0 0 0; + margin: 1em 0 0; } .wrapper-downloads { - display: flex; + @include media-breakpoint-up(md) { + display: flex; + } .hd { margin: 0; @@ -154,8 +156,8 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark color: theme-color("primary"); } - .btn-play:after { - background: $white; + .btn-play::after { + background: theme-color("inverse"); } } @@ -176,7 +178,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark padding: 30px; border-radius: 25%; - &:after{ + &::after { @include animation(rotateCW 3s infinite linear); content: ''; @@ -200,7 +202,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark font-size: 4em; cursor: pointer; - &:after { + &::after { background: $white; position: absolute; width: 50%; @@ -229,17 +231,17 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark max-height: ($baseline * 3); border-radius: ($baseline / 5); padding: 8px ($baseline / 2) 8px ($baseline * 1.5); - background: rgba(0, 0, 0, .75); + background: rgba(0, 0, 0, 0.75); color: $yellow; - &:before { + &::before { position: absolute; display: inline-block; top: 50%; @include left($baseline); - margin-top: -.6em; + margin-top: -0.6em; font-family: 'FontAwesome'; content: "\f142"; color: $white; @@ -248,11 +250,11 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark &:hover, &.is-dragging { - background: rgba(0, 0, 0, 1.0); + background: rgba(0, 0, 0, 1); cursor: move; - &:before { - opacity: 1.0; + &::before { + opacity: 1; } } } @@ -269,7 +271,8 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark } } - .video-error, .video-hls-error { + .video-error, + .video-hls-error { padding: ($baseline / 5); background: black; color: white !important; // the pattern library headings shim is more scoped @@ -355,7 +358,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark .slider { @include clearfix(); @include transform-origin(bottom left); - @include transition(height .7s ease-in-out 0s); + @include transition(height 0.7s ease-in-out 0s); box-sizing: border-box; position: absolute; @@ -386,7 +389,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark @extend %ui-fake-link; @include transform-origin(bottom left); - @include transition(all .7s ease-in-out 0s); + @include transition(all 0.7s ease-in-out 0s); box-sizing: border-box; top: -1px; @@ -436,13 +439,13 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark @extend %t-strong; @extend %t-title7; - @include padding-left(lh(.75)); + @include padding-left(lh(0.75)); display: inline-block; color: rgb(207, 216, 220); // UXPL grayscale-cool light - -webkit-font-smoothing: antialiased;; + -webkit-font-smoothing: antialiased; - @media (max-width: 1120px) { + @media (max-width: 1120px) { @include padding-left(lh(0.5)); } } @@ -801,7 +804,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark &.closed { .video-wrapper { - width: flex-grid(9,9); + width: flex-grid(9, 9); background-color: inherit; } @@ -846,7 +849,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark &.video-fullscreen { @extend %ui-depth4; - background: rgba(#000, .95); + background: rgba(#000, 0.95); border: 0; bottom: 0; height: 100%; @@ -899,7 +902,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark object, iframe, - video{ + video { position: absolute; width: auto; height: auto; @@ -974,7 +977,7 @@ $cool-dark: rgb(79, 89, 93); // UXPL cool dark background: $black-t2; box-shadow: none; - &:after { + &::after { // the button class, ties to functionality, also uses an icon font // we're overriding it here so we can use our image instead display: none; diff --git a/common/static/sass/_mixins-inherited.scss b/common/static/sass/_mixins-inherited.scss index 2cd667eefd..f2055eb61f 100644 --- a/common/static/sass/_mixins-inherited.scss +++ b/common/static/sass/_mixins-inherited.scss @@ -141,7 +141,8 @@ display: inline-block; padding: ($baseline/5) $baseline ($baseline/4); - &.disabled, &.is-disabled { + &.disabled, + &.is-disabled { border: 1px solid $gray-l1 !important; border-radius: 3px !important; background: $gray-l1 !important; @@ -149,12 +150,15 @@ pointer-events: none; cursor: none; - &:hover, &:focus { + &:hover, + &:focus { box-shadow: 0 0 0 0 !important; } } - &:hover, &:focus, &:active { + &:hover, + &:focus, + &:active { box-shadow: 0 1px 0 rgba(255, 255, 255, 0.3) inset, 0 1px 1px rgba(0, 0, 0, 0.15); } } diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss index 0e0b3707b0..0e2813edc4 100644 --- a/common/static/sass/_mixins.scss +++ b/common/static/sass/_mixins.scss @@ -28,14 +28,14 @@ // +Font Sizing - Mixin // ==================== -@mixin font-size($sizeValue: 16){ +@mixin font-size($sizeValue: 16) { font-size: $sizeValue + px; font-size: ($sizeValue/10) + rem; } // +Line Height - Mixin // ==================== -@mixin line-height($fontSize: auto){ +@mixin line-height($fontSize: auto) { line-height: ($fontSize*1.48) + px; line-height: (($fontSize/10)*1.48) + rem; } @@ -120,14 +120,14 @@ } // layout placeholders -.ui-col-wide { +.ui-col-wide { width: flex-grid(9, 12); @include margin-right(flex-gutter()); @include float(left); } -.ui-col-narrow { +.ui-col-narrow { width: flex-grid(3, 12); @include float(left); @@ -145,7 +145,8 @@ background: $white; // STATE: hover/active - &:hover, &:active { + &:hover, + &:active { box-shadow: 0 1px 1px $shadow; } } @@ -203,11 +204,9 @@ display: inline-block; cursor: pointer; - &:hover, &:active { - - } - - &.disabled, &[disabled], &.is-disabled { + &.disabled, + &[disabled], + &.is-disabled { cursor: default; pointer-events: none; border: 1px solid $gray-l3; @@ -237,21 +236,26 @@ @extend %ui-btn-pill; @extend %t-strong; - padding:($baseline/2) $baseline; + padding: ($baseline/2) $baseline; border-width: 1px; border-style: solid; box-shadow: none; line-height: 1.5em; text-align: center; - &:hover, &:active, &:focus { + &:hover, + &:active, + &:focus { box-shadow: 0 2px 1px $shadow; } - &.current, &.active { + &.current, + &.active { box-shadow: inset 1px 1px 2px $shadow-d1; - &:hover, &:active, &:focus { + &:hover, + &:active, + &:focus { box-shadow: inset 1px 1px 1px $shadow-d1; } } @@ -264,14 +268,14 @@ border-width: 1px; border-style: solid; - padding:($baseline/2) $baseline; + padding: ($baseline/2) $baseline; background: transparent; line-height: 1.5em; text-align: center; } %ui-btn-flat-outline { - @include transition(all .15s); + @include transition(all 0.15s); @extend %t-strong; @extend %t-action4; @@ -283,14 +287,15 @@ background-color: theme-color("inverse"); color: theme-color("primary"); - &:hover, &:focus { + &:hover, + &:focus { border: 1px solid $uxpl-blue-hover-active; background-color: $uxpl-blue-hover-active; color: theme-color("inverse"); } &.is-disabled, - &[disabled="disabled"]{ + &[disabled="disabled"] { border: 1px solid $gray-l2; background-color: $gray-l4; color: $gray-l2; @@ -300,7 +305,7 @@ // button with no button shell until hover for understated actions %ui-btn-non { - @include transition(all .15s); + @include transition(all 0.15s); @extend %ui-btn-pill; @@ -313,7 +318,8 @@ background: none; color: $gray-l1; - &:hover, &:focus { + &:hover, + &:focus { background-color: $gray-l1; color: $white; } @@ -323,7 +329,8 @@ %ui-btn-non-blue { @extend %ui-btn-non; - &:hover, &:focus { + &:hover, + &:focus { background-color: theme-color("primary"); color: theme-color("inverse"); } @@ -363,7 +370,7 @@ @extend %ui-well; @extend %t-copy-base; - opacity: .6; + opacity: 0.6; background-color: $white; padding: ($baseline*1.5) $baseline; text-align: center; diff --git a/common/static/sass/bourbon/addons/_button.scss b/common/static/sass/bourbon/addons/_button.scss index 14a89e480c..3a8279f995 100644 --- a/common/static/sass/bourbon/addons/_button.scss +++ b/common/static/sass/bourbon/addons/_button.scss @@ -349,7 +349,7 @@ text-decoration: none; background-clip: padding-box; - &:hover:not(:disabled){ + &:hover:not(:disabled) { $base-color-hover: adjust-color($base-color, $saturation: 4%, $lightness: 5%); @if $grayscale == true { diff --git a/common/static/sass/bourbon/functions/_tint-shade.scss b/common/static/sass/bourbon/functions/_tint-shade.scss index f7172004ac..d1198d0367 100644 --- a/common/static/sass/bourbon/functions/_tint-shade.scss +++ b/common/static/sass/bourbon/functions/_tint-shade.scss @@ -1,9 +1,9 @@ // Add percentage of white to a color -@function tint($color, $percent){ +@function tint($color, $percent) { @return mix(white, $color, $percent); } // Add percentage of black to a color -@function shade($color, $percent){ +@function shade($color, $percent) { @return mix(black, $color, $percent); } diff --git a/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss b/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss index 8f12ea7b9f..b21ebb9b0b 100644 --- a/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss +++ b/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss @@ -12,6 +12,17 @@ display: inline-block; + @include media-breakpoint-down(md) { + max-width: 60px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + &.nav-item-course { + max-width: none; + } + a, a:visited { color: theme-color("primary"); @@ -25,6 +36,11 @@ .fa-angle-right { @include margin-left($baseline/4); + @include media-breakpoint-down(md) { + position: relative; + top: -5px; + } + display: inline-block; color: $body-color; diff --git a/lms/static/sass/_experiments.scss b/lms/static/sass/_experiments.scss index 543178e36b..e63167ded8 100644 --- a/lms/static/sass/_experiments.scss +++ b/lms/static/sass/_experiments.scss @@ -141,7 +141,8 @@ font-size: 14px !important; font-weight: 500 !important; - &:hover, &:focus { + &:hover, + &:focus { background-color: #009b00 !important; border-color: #009b00; box-shadow: #004d00 0 2px 1px 0; diff --git a/lms/static/sass/_variables.scss b/lms/static/sass/_variables.scss index 6b736b4105..2f3b04240d 100644 --- a/lms/static/sass/_variables.scss +++ b/lms/static/sass/_variables.scss @@ -1,5 +1,7 @@ // LMS-specific variables +$text-width-readability-max: 900px; + // LMS-only colors $audit-mode-color: rgb(74, 74, 74) !default; $honor-mode-color: theme-color("primary") !default; diff --git a/lms/static/sass/base/_base.scss b/lms/static/sass/base/_base.scss index 8ab416601c..5f7bc64587 100644 --- a/lms/static/sass/base/_base.scss +++ b/lms/static/sass/base/_base.scss @@ -130,9 +130,13 @@ a:visited:not(.btn) { } .content-wrapper { - width: flex-grid(12); - margin: 0 auto; - background: $body-bg; + max-width: map-get($container-max-widths, xl); + margin-top: $baseline; + padding: 0 0 $baseline/2; + + @include media-breakpoint-up(md) { + padding: 0 $baseline $baseline/2; + } @media print { padding-bottom: 0; diff --git a/lms/static/sass/base/_layouts.scss b/lms/static/sass/base/_layouts.scss index 95f6d8bdaf..c9ee599de7 100644 --- a/lms/static/sass/base/_layouts.scss +++ b/lms/static/sass/base/_layouts.scss @@ -6,11 +6,6 @@ body.view-in-course { background-color: $body-bg; - // keep application of widths to window-wrap - .window-wrap { - min-width: 760px; - } - // courseware header .header-global, .header-global.slim { @@ -19,7 +14,8 @@ body.view-in-course { .wrapper-header { min-width: auto; - .user-dropdown, .dropdown { + .user-dropdown, + .dropdown { padding: ($baseline/2); } } @@ -41,7 +37,7 @@ body.view-in-course { } .wrapper-course-material .course-material { - padding: ($baseline/2) 0 0 0; + padding: 0; } .wrapper-course-material .course-material .course-tabs { @@ -53,7 +49,6 @@ body.view-in-course { max-width: none; min-width: initial; width: auto; - padding: 0 2%; } // course info page diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index dadc9db13a..fa9e5b6c97 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -2,13 +2,13 @@ // ==================== // mixins - font sizing -@mixin font-size($sizeValue: 16){ +@mixin font-size($sizeValue: 16) { font-size: $sizeValue + px; // font-size: ($sizeValue/10) + rem; } // mixins - line height -@mixin line-height($fontSize: auto){ +@mixin line-height($fontSize: auto) { line-height: ($fontSize*1.48) + px; // line-height: (($fontSize/10)*1.48) + rem; } @@ -31,7 +31,7 @@ } // sunsetted, but still used mixins -@mixin hide-text(){ +@mixin hide-text() { text-indent: -9999px; overflow: hidden; display: block; diff --git a/lms/static/sass/bootstrap/_layouts.scss b/lms/static/sass/bootstrap/_layouts.scss index ef10235e74..4263e0326d 100644 --- a/lms/static/sass/bootstrap/_layouts.scss +++ b/lms/static/sass/bootstrap/_layouts.scss @@ -1,13 +1,29 @@ // LMS layouts .content-wrapper { + max-width: map-get($container-max-widths, xl); margin-top: $baseline; - padding-bottom: $baseline/2; + padding: 0 0 $baseline/2; + + @include media-breakpoint-up(md) { + padding: 0 $baseline $baseline/2; + } .course-tabs { - padding: 0 $baseline*2; + padding: 0; font-size: $font-size-sm; + @include media-breakpoint-down(md) { + overflow-x: scroll; + overflow-y: hidden; + white-space: nowrap; + } + + .navbar-nav { + display: flex; + flex-direction: row; + } + .nav-item { .nav-link { padding: $baseline/2 $baseline*3/4 $baseline*13/20; @@ -15,12 +31,6 @@ border-width: 0 0 $baseline/5 0; border-bottom-color: transparent; color: theme-color("secondary"); - - @include media-breakpoint-down(md) { - border: none; - text-align: left; - padding: 0 0 $baseline/2 0; - } } &.active, diff --git a/lms/static/sass/course/_info.scss b/lms/static/sass/course/_info.scss index 7f0d2032a2..65945e3896 100644 --- a/lms/static/sass/course/_info.scss +++ b/lms/static/sass/course/_info.scss @@ -1,8 +1,8 @@ //// Notifications // Upgrade -$notification-highlight-border-color: $uxpl-green-base !default; -$notification-background: rgb(255, 255, 255) !default +$notification-highlight-border-color: $uxpl-green-base !default; +$notification-background: rgb(255, 255, 255) !default .home { @include clearfix(); @@ -60,7 +60,7 @@ div.info-wrapper { div.upgrade-banner { // This banner uses the Pattern Library's defined variables - @include border-left(0px); + @include border-left(0); border: 1px solid $border-color; width: 100%; diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index 8255532cd8..2284e7423d 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -23,12 +23,14 @@ h1.top-header { text-transform: none; letter-spacing: 0; - &:hover, &:focus { + &:hover, + &:focus { text-decoration: none; } } -.light-button, a.light-button, // only used in askbot as classes +.light-button, +a.light-button, // only used in askbot as classes .gray-button { @include simple($gray-l5); @@ -130,9 +132,10 @@ h1.top-header { line-height: lh(); font-size: 1em; box-sizing: border-box; - padding: lh(.25) lh(0.5) lh(.25) 0; + padding: lh(0.25) lh(0.5) lh(0.25) 0; - &:hover, &:focus { + &:hover, + &:focus { color: #666; background: $gray-l6; } @@ -156,7 +159,8 @@ h1.top-header { width: 16px; z-index: 99; - &:hover, &:focus { + &:hover, + &:focus { background-color: white; } } @@ -181,7 +185,8 @@ h1.top-header { border-left: 1px solid lighten($border-color, 10%); display: block; - &:hover, &:focus { + &:hover, + &:focus { background: none; } } diff --git a/lms/static/sass/course/base/_mixins.scss b/lms/static/sass/course/base/_mixins.scss index b53f05462d..e821e3c86e 100644 --- a/lms/static/sass/course/base/_mixins.scss +++ b/lms/static/sass/course/base/_mixins.scss @@ -15,7 +15,8 @@ text-shadow: 0 1px 0 rgba(0, 0, 0, .3); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); - &:hover, &:focus { + &:hover, + &:focus { border-color: #297095; @include linear-gradient(top, #4fbbe4, #2090d0); @@ -38,7 +39,8 @@ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); - &:hover, &:focus { + &:hover, + &:focus { @include linear-gradient(top, #fff, #ddd); } } @@ -57,7 +59,8 @@ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.6); box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset, 0 1px 1px rgba(0, 0, 0, .15); - &:hover, &:focus { + &:hover, + &:focus { background: -webkit-linear-gradient(top, #888, #666); } } diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index a7da70c8c2..683249ead3 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -19,6 +19,11 @@ html.video-fullscreen { @extend %ui-print-excluded; margin: ($baseline/2) ($baseline/4) 0 0; + display: none; + + @include media-breakpoint-up(md) { + display: block; + } &.studio-view { margin: 0; @@ -100,7 +105,6 @@ html.video-fullscreen { } } -// TO-DO should this be content wrapper? .course-wrapper { position: relative; @@ -132,6 +136,12 @@ html.video-fullscreen { word-break: break-word; } + // Make text-focused blocks have a maximum width for readability. + .xmodule_HtmlModule, + .xmodule_CapaModule { + max-width: $text-width-readability-max; + } + h1 { margin: 0 0 lh(); letter-spacing: 0; diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index c60f51a565..538355d6f9 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -78,7 +78,7 @@ // TYPE: warning .msg-warning { display: none; - background: tint($warning-color,95%); + background: tint($warning-color, 95%); border-top: 2px solid $warning-color; color: $warning-color; } @@ -203,7 +203,7 @@ height: 24px; // To match bull browse button width: 124%; margin: 0; - padding: 4px 0 0 0; + padding: 4px 0 0; cursor: pointer; // for visual sync, need to make button similar to firefox @@ -376,7 +376,7 @@ // type - warning .message-warning { border-top: 2px solid $warning-color; - background: tint($warning-color,95%); + background: tint($warning-color, 95%); .message-title { color: $warning-color; @@ -571,7 +571,7 @@ @include columns(2); .subheading { - font-size: .9em; + font-size: 0.9em; } } @@ -608,7 +608,8 @@ } } - .batch-enrollment, .batch-beta-testers { + .batch-enrollment, + .batch-beta-testers { textarea { margin-top: 0.2em; height: auto; @@ -637,10 +638,6 @@ } // Auto Enroll Csv Section .auto_enroll_csv { - .results { - - } - .enrollment_signup_button { @include margin-right($baseline/4); } @@ -867,7 +864,8 @@ } } - .form-submit, .form-cancel { + .form-submit, + .form-cancel { display: inline-block; vertical-align: middle; } @@ -919,7 +917,7 @@ } .action-create { - opacity: 0.50; + opacity: 0.5; } } } @@ -979,7 +977,9 @@ padding-bottom: ($baseline/2); border-bottom: 1px solid $gray-l4; - &:hover, &:active, &:focus { + &:hover, + &:active, + &:focus { .action-edit-name { opacity: 1; pointer-events: auto; @@ -987,7 +987,9 @@ } } - .title-value, .group-count, .action-edit { + .title-value, + .group-count, + .action-edit { display: inline-block; vertical-align: middle; } @@ -1427,7 +1429,7 @@ margin-top: 0.7em; } - .task-history-all-table { + .task-history-all-table { margin-top: 1em; } @@ -1480,7 +1482,8 @@ // view - metrics // -------------------- .instructor-dashboard-wrapper-2 section.idash-section#metrics { - .metrics-container, .metrics-header-container { + .metrics-container, + .metrics-header-container { position: relative; clear: both; width: 100%; @@ -1505,7 +1508,8 @@ height: 640px; } - .metrics-right, .metrics-right-header { + .metrics-right, + .metrics-right-header { position: relative; width: 65%; @@ -1575,7 +1579,8 @@ background-color: #ddd; } - th, td { + th, + td { padding: 10px; } } @@ -1659,7 +1664,7 @@ input[name="subject"] { } .ecommerce-wrapper { - h2{ + h2 { height: 26px; line-height: 26px; @@ -1759,7 +1764,8 @@ input[name="subject"] { } input[name="download_company_name"], - input[name="active_company_name"], input[name="spent_company_name"] { + input[name="active_company_name"], + input[name="spent_company_name"] { @include margin-right(8px); height: 36px; @@ -1770,7 +1776,7 @@ input[name="subject"] { .coupons-table { width: 100%; - tr:nth-child(even){ + tr:nth-child(even) { background-color: $gray-l6; border-bottom: 1px solid #f3f3f3; } @@ -1792,7 +1798,7 @@ input[name="subject"] { height: 40px; border-bottom: 1px solid #bebebe; - th:nth-child(5){ + th:nth-child(5) { text-align: center; width: 120px; } @@ -1840,7 +1846,7 @@ input[name="subject"] { // in_active coupon rows style .inactive_coupon { - background: #fff0f0 !important; + background: #fff0f0 !important; text-decoration: line-through; color: rgba(51, 51, 51, 0.2); border-bottom: 1px solid #fff; @@ -1882,19 +1888,20 @@ input[name="subject"] { } } - td:nth-child(5),td:first-child { + td:nth-child(5), + td:first-child { @include padding-left($baseline); } - td:nth-child(2){ + td:nth-child(2) { line-height: 22px; - @include padding-right(0px); + @include padding-right(0); word-wrap: break-word; } - td:nth-child(5){ + td:nth-child(5) { @include padding-left(0); text-align: center; @@ -1914,8 +1921,11 @@ input[name="subject"] { width: 930px; } // coupon edit and add modals - #add-coupon-modal, #invalidate_registration_code_modal, #edit-coupon-modal, - #set-course-mode-price-modal, #registration_code_generation_modal { + #add-coupon-modal, + #invalidate_registration_code_modal, + #edit-coupon-modal, + #set-course-mode-price-modal, + #registration_code_generation_modal { .inner-wrapper { background: $white; } @@ -1924,7 +1934,7 @@ input[name="subject"] { display: block; margin-top: ($baseline/4); font-size: 12px; - color: #646464 + color: #646464; } width: 650px; @@ -1933,8 +1943,10 @@ input[name="subject"] { border-radius: 2px; - input[type="button"]#update_coupon_button, input[type="button"]#add_coupon_button, - input[type="button"]#set_course_button, input[type="button"]#lookup_regcode { + input[type="button"]#update_coupon_button, + input[type="button"]#add_coupon_button, + input[type="button"]#set_course_button, + input[type="button"]#lookup_regcode { @include button(simple, $primary); @extend .button-reset; @@ -1978,14 +1990,15 @@ input[name="subject"] { } } - li:nth-child(even){ + li:nth-child(even) { @include margin-left(30px !important); } - li:nth-child(3), li:nth-child(4){ + li:nth-child(3), + li:nth-child(4) { width: 100%; - @include margin-left(0px !important); + @include margin-left(0 !important); } li:nth-child(3) { @@ -2058,8 +2071,8 @@ input[name="subject"] { margin-bottom: $baseline; } - li:nth-child(even){ - @include margin-left(0px !important); + li:nth-child(even) { + @include margin-left(0 !important); } li:nth-child(3n) { @@ -2082,7 +2095,7 @@ input[name="subject"] { min-height: 5px; - @include margin-left(0px !important); + @include margin-left(0 !important); input[type='checkbox'] { width: auto; @@ -2090,9 +2103,9 @@ input[name="subject"] { } } - li#generate-registration-modal-field-country ~ li#generate-registration-modal-field-unit-price, - li#generate-registration-modal-field-country ~ li#generate-registration-modal-field-internal-reference { - @include margin-left(0px !important); + li#generate-registration-modal-field-country ~ li#generate-registration-modal-field-unit-price, + li#generate-registration-modal-field-country ~ li#generate-registration-modal-field-internal-reference { + @include margin-left(0 !important); @include margin-right(15px !important); } @@ -2111,7 +2124,7 @@ input[name="subject"] { } li#set-course-mode-modal-field-currency { - @include margin-left(0px !important); + @include margin-left(0 !important); select { width: 100%; @@ -2125,7 +2138,11 @@ input[name="subject"] { border-radius: 3px; } - #coupon-content, #course-content, #content, #registration-content, #regcode-content { + #coupon-content, + #course-content, + #content, + #registration-content, + #regcode-content { padding: $baseline; header { @@ -2178,7 +2195,7 @@ input[name="subject"] { } .field label { - margin: 0 0 5px 0; + margin: 0 0 5px; -webkit-transition: color 0.15s ease-in-out 0s; -moz-transition: color 0.15s ease-in-out 0s; transition: color 0.15s ease-in-out 0s; @@ -2259,8 +2276,9 @@ input[name="subject"] { } } -.ecommerce-wrapper, .proctoring-wrapper { - h2{ +.ecommerce-wrapper, +.proctoring-wrapper { + h2 { height: 26px; line-height: 26px; @@ -2361,20 +2379,24 @@ input[name="subject"] { } } -.special-allowance-container, .student-proctored-exam-container { - .allowance-table, .exam-attempts-table { +.special-allowance-container, +.student-proctored-exam-container { + .allowance-table, + .exam-attempts-table { width: 100%; - tr:nth-child(even){ + tr:nth-child(even) { background-color: $gray-l6; border-bottom: 1px solid #f3f3f3; } - .allowance-headings, .exam-attempt-headings { + .allowance-headings, + .exam-attempt-headings { height: 40px; border-bottom: 1px solid #bebebe; - th:nth-child(5), th:nth-child(4){ + th:nth-child(5), + th:nth-child(4) { text-align: center; } @@ -2456,26 +2478,28 @@ input[name="subject"] { @include padding-left($baseline); } - td:nth-child(2){ + td:nth-child(2) { line-height: 22px; - @include padding-right(0px); + @include padding-right(0); word-wrap: break-word; } - td:nth-child(5), td:nth-child(4), td:nth-child(6){ + td:nth-child(5), + td:nth-child(4), + td:nth-child(6) { @include padding-left(0); text-align: center; } - td:nth-child(3){ + td:nth-child(3) { word-wrap: break-word; text-align: center; } - td:nth-child(7){ + td:nth-child(7) { word-wrap: break-word; text-align: center; } @@ -2486,7 +2510,8 @@ input[name="subject"] { } } - .exam-attempts-content, .exam-allowances-content { + .exam-attempts-content, + .exam-allowances-content { padding-left: 0; padding-right: 0; } @@ -2626,7 +2651,7 @@ input[name="subject"] { } p.under-heading { - margin: 12px 0 12px 0; + margin: 12px 0; line-height: 23px; } @@ -2654,7 +2679,7 @@ input[name="subject"] { td { padding: 5px; vertical-align: middle; - text-align: left;; + text-align: left; } } } diff --git a/lms/static/sass/course/layout/_courseware_header.scss b/lms/static/sass/course/layout/_courseware_header.scss index de02fc6006..5f446ab492 100644 --- a/lms/static/sass/course/layout/_courseware_header.scss +++ b/lms/static/sass/course/layout/_courseware_header.scss @@ -5,12 +5,18 @@ @extend %ui-print-excluded; border-bottom: none; - margin: 0 auto 0; + margin: 0 auto; padding: 0; width: 100%; .course-material { @extend %inner-wrapper; + + @include media-breakpoint-down(md) { + overflow-x: scroll; + overflow-y: hidden; + white-space: nowrap; + } } .course-tabs { @@ -19,7 +25,7 @@ padding: ($baseline*0.75) 0 ($baseline*0.75) 0; - li { + .tab { display: inline-block; list-style: none; @@ -42,7 +48,7 @@ @extend %t-title7; @extend %t-regular; - color: $gray-d1; + color: theme-color("dark"); display: block; text-align: center; text-decoration: none; @@ -51,8 +57,8 @@ &:hover, &:focus, &.active { - color: $uxpl-blue-hover-active; - border-bottom-color: $uxpl-blue-hover-active; + color: theme-color("primary"); + border-bottom-color: theme-color("primary"); background-color: transparent; } } @@ -92,8 +98,6 @@ display: none; &#login { - display: block; - @include background-image(linear-gradient(-90deg, lighten($link-color, 8%), lighten($link-color, 5%) 50%, $link-color 50%, darken($link-color, 10%) 100%)); border: 1px solid transparent; @@ -103,12 +107,11 @@ @include box-sizing(border-box); box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.6); - color: $white; + color: theme-color("inverse"); display: inline-block; font-family: $font-family-sans-serif; - font-size: 14px; + font-size: $font-size-sm; font-weight: bold; - display: inline-block; letter-spacing: 0; line-height: 1em; margin: 4px; @@ -118,7 +121,9 @@ text-shadow: 0 -1px rgba(0, 0, 0, 0.6); vertical-align: middle; - &:hover, &:active, &:focus { + &:hover, + &:active, + &:focus { @include background-image(linear-gradient(-90deg, $primary, $primary 50%, $primary 50%, $primary 100%)); } } @@ -156,7 +161,6 @@ font: inherit; font-weight: bold; } - } a#signup { diff --git a/lms/static/sass/course/layout/_courseware_preview.scss b/lms/static/sass/course/layout/_courseware_preview.scss index 4f2e932c97..35a2d1f6a7 100644 --- a/lms/static/sass/course/layout/_courseware_preview.scss +++ b/lms/static/sass/course/layout/_courseware_preview.scss @@ -1,7 +1,7 @@ .wrapper-preview-menu { @include clearfix(); - margin: 0 auto 0; + margin: 0 auto; padding: ($baseline*0.75); background-color: $lms-preview-menu-color; box-sizing: border-box; diff --git a/lms/static/sass/course/wiki/_basic-html.scss b/lms/static/sass/course/wiki/_basic-html.scss index 0224ddfbd2..795391d27a 100644 --- a/lms/static/sass/course/wiki/_basic-html.scss +++ b/lms/static/sass/course/wiki/_basic-html.scss @@ -6,7 +6,32 @@ section.wiki-body { } div#wiki_article { - html, address, blockquote, body, dd, div, dl, dt, fieldset, form, frame, frameset, h1, h2, h3, h4, h5, h6, noframes, ol, p, ul, center, dir, hr, menu, pre { + html, + address, + blockquote, + body, + dd, + div, + dl, + dt, + fieldset, + form, + frame, + frameset, + h1, + h2, + h3, + h4, + h5, + h6, + ol, + p, + ul, + center, + dir, + hr, + menu, + pre { display: block; unicode-bidi: embed; } @@ -47,7 +72,8 @@ section.wiki-body { display: table-column-group; } - td, th { + td, + th { display: table-cell; } @@ -66,14 +92,14 @@ section.wiki-body { h1 { font-size: 1.6em; - margin: .67em 0; + margin: 0.67em 0; letter-spacing: 0; } h2 { text-transform: none; font-size: 1.4em; - margin: .75em 0; + margin: 0.75em 0; letter-spacing: 0; } @@ -86,7 +112,16 @@ section.wiki-body { font-size: 1.1em; } - h4, p, blockquote, ul, fieldset, form, ol, dl, dir, menu { + h4, + p, + blockquote, + ul, + fieldset, + form, + ol, + dl, + dir, + menu { margin: 1.12em 0; } @@ -100,7 +135,8 @@ section.wiki-body { margin: 1.67em 0; } - b, strong { + b, + strong { font-weight: bolder; } @@ -110,11 +146,19 @@ section.wiki-body { border-left: 4px solid; } - i, cite, em, var, address { + i, + cite, + em, + var, + address { font-style: italic; } - pre, tt, code, kbd, samp { + pre, + tt, + code, + kbd, + samp { font-family: monospace; } @@ -122,7 +166,10 @@ section.wiki-body { white-space: pre; } - button, textarea, input, select { + button, + textarea, + input, + select { display: inline-block; } @@ -130,7 +177,9 @@ section.wiki-body { font-size: 1.17em; } - small, sub, sup { + small, + sub, + sup { font-size: 0.83em; } @@ -146,15 +195,21 @@ section.wiki-body { border-spacing: 2px; } - thead, tbody, tfoot { + thead, + tbody, + tfoot { vertical-align: middle; } - td, th, tr { + td, + th, + tr { vertical-align: inherit; } - s, strike, del { + s, + strike, + del { text-decoration: line-through; } @@ -164,7 +219,11 @@ section.wiki-body { border: none; } - ol, ul, dir, menu, dd { + ol, + ul, + dir, + menu, + dd { margin-left: 40px; } @@ -172,12 +231,16 @@ section.wiki-body { list-style-type: decimal; } - ol ul, ul ol, ul ul, ol ol { + ol ul, + ul ol, + ul ul, + ol ol { margin-top: 0; margin-bottom: 0; } - u, ins { + u, + ins { text-decoration: underline; } diff --git a/lms/static/sass/course/wiki/_create.scss b/lms/static/sass/course/wiki/_create.scss index 8e283bb7fc..664ce93bb8 100644 --- a/lms/static/sass/course/wiki/_create.scss +++ b/lms/static/sass/course/wiki/_create.scss @@ -5,7 +5,7 @@ form#wiki_revision { label { display: block; - margin-bottom: 7px ; + margin-bottom: 7px; } .CodeMirror-scroll { @@ -56,7 +56,8 @@ form#wiki_revision { margin-top: lh(); width: flex-grid(3, 9); - &:hover, &:focus { + &:hover, + &:focus { color: #333; } diff --git a/lms/static/sass/discussion/utilities/_v1-compatibility.scss b/lms/static/sass/discussion/utilities/_v1-compatibility.scss index bf384d477a..787a4fcd44 100644 --- a/lms/static/sass/discussion/utilities/_v1-compatibility.scss +++ b/lms/static/sass/discussion/utilities/_v1-compatibility.scss @@ -1,10 +1,10 @@ // Utilities to provide v1-styling compatibility -@mixin font-size($sizeValue: 16){ +@mixin font-size($sizeValue: 16) { font-size: $sizeValue + px; } -@mixin line-height($fontSize: auto){ +@mixin line-height($fontSize: auto) { line-height: ($fontSize*1.48) + px; } diff --git a/lms/static/sass/features/_bookmarks-v1.scss b/lms/static/sass/features/_bookmarks-v1.scss index 776b461932..580983f88f 100644 --- a/lms/static/sass/features/_bookmarks-v1.scss +++ b/lms/static/sass/features/_bookmarks-v1.scss @@ -52,6 +52,8 @@ $bookmarked-icon: "\f02e"; // .fa-bookmark .bookmark-button { &::before { + @include padding-right($baseline / 4); + content: $bookmark-icon; font-family: FontAwesome; } diff --git a/lms/static/sass/multicourse/_about_pages.scss b/lms/static/sass/multicourse/_about_pages.scss index c49d63b3e9..3185341ef7 100644 --- a/lms/static/sass/multicourse/_about_pages.scss +++ b/lms/static/sass/multicourse/_about_pages.scss @@ -27,8 +27,10 @@ text-transform: lowercase; - &:hover, &:active, &:focus { - border-color: rgb(200,200,200); + &:hover, + &:active, + &:focus { + border-color: rgb(200, 200, 200); color: $body-color; text-decoration: none; } @@ -41,7 +43,7 @@ } .our-mission { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid rgb(220, 220, 220); @include clearfix(); @@ -49,7 +51,7 @@ padding-bottom: 40px; .logo { - @include border-right(1px solid rgb(200,200,200)); + @include border-right(1px solid rgb(200, 200, 200)); @include box-sizing(border-box); @include float(left); @@ -83,7 +85,7 @@ } .message { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid rgb(220, 220, 220); @include clearfix(); @@ -100,7 +102,7 @@ } h2 { - border-bottom: 1px solid rgb(200,200,200); + border-bottom: 1px solid rgb(200, 200, 200); padding-bottom: 15px; } @@ -114,7 +116,7 @@ width: flex-grid(3); img { - background: rgb(245,245,245); + background: rgb(245, 245, 245); display: block; width: 100%; } @@ -167,7 +169,7 @@ @include clearfix(); nav.categories { - border: 1px solid rgb(220,220,220); + border: 1px solid rgb(220, 220, 220); @include box-sizing(border-box); @include float(left); @@ -185,8 +187,9 @@ text-align: left; - &:hover, &:focus { - background: rgb(245,245,245); + &:hover, + &:focus { + background: rgb(245, 245, 245); text-decoration: none; } } @@ -205,7 +208,7 @@ } > h2 { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid rgb(220, 220, 220); margin-bottom: ($baseline*2); padding-bottom: $baseline; } @@ -225,7 +228,7 @@ .press { .press-story { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid rgb(220, 220, 220); @include clearfix(); @@ -240,7 +243,7 @@ .article-cover { background: rgb(255, 255, 255); - border: 1px solid rgb(120,120,120); + border: 1px solid rgb(120, 120, 120); @include box-sizing(border-box); @include float(left); diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index 2053eab67b..0899945a14 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -92,7 +92,6 @@ padding: $baseline/2 $baseline*2.5; text-transform: lowercase; color: $very-light-text; - letter-spacing: 0.1rem; font-weight: 600; cursor: pointer; text-align: center; @@ -194,7 +193,12 @@ @extend %body-text; } - h1, h2, h3, h4, h5, h6 { + h1, + h2, + h3, + h4, + h5, + h6 { letter-spacing: 0; } @@ -506,7 +510,8 @@ @extend %m-btn-primary; @extend %t-action2; - &:disabled, &.is-disabled { + &:disabled, + &.is-disabled { opacity: 0.3; cursor: default !important; } @@ -564,7 +569,7 @@ } .form-actions.form-third-party-auth { - width: flex-grid(8,8); + width: flex-grid(8, 8); margin-bottom: $baseline; button[type="submit"] { @@ -615,7 +620,6 @@ &.button-oa2-linkedin-oauth2:hover { box-shadow: 0 2px 1px 0 #005d8e; } - } } @@ -626,7 +630,7 @@ margin: 0 0 $baseline 0; border-bottom: 3px solid shade($yellow, 10%); padding: $baseline $baseline; - background: tint($yellow,20%); + background: tint($yellow, 20%); .message-title { @extend %heading-4; diff --git a/lms/static/sass/multicourse/_courses.scss b/lms/static/sass/multicourse/_courses.scss index b583c8fdb5..5a49dc77d7 100644 --- a/lms/static/sass/multicourse/_courses.scss +++ b/lms/static/sass/multicourse/_courses.scss @@ -23,14 +23,15 @@ $facet-background-color: #007db8; // +Layout - Courses Container // ==================== -.find-courses, .university-profile { +.find-courses, +.university-profile { .discovery-button:not(:disabled) { @extend %t-action2; @include text-align(left); outline: 0 none; - box-shadow:none; + box-shadow: none; border: 0; background: none; padding: 0 ($baseline*0.6); @@ -39,7 +40,7 @@ $facet-background-color: #007db8; text-transform: none; //STATE: hover - &::hover { + &:hover { background: none; } } @@ -131,7 +132,8 @@ $facet-background-color: #007db8; // +Hero - Home Header // ==================== -.find-courses, .university-profile { +.find-courses, +.university-profile { header.search { background: $gray-l5; background-size: cover; @@ -162,7 +164,8 @@ $facet-background-color: #007db8; z-index: 2; } - &.main-search, &.university-search { + &.main-search, + &.university-search { text-align: center; .heading-group { @@ -180,7 +183,7 @@ $facet-background-color: #007db8; vertical-align: middle; &::after { - @include right(0px); + @include right(0); content: ""; display: block; @@ -203,7 +206,8 @@ $facet-background-color: #007db8; text-transform: none; } - h1, h2 { + h1, + h2 { display: inline-block; letter-spacing: 1px; margin-bottom: 0; @@ -247,7 +251,6 @@ $facet-background-color: #007db8; @include media($bp-large) { @include span-columns(8); } - } .wrapper-search-input { @@ -318,7 +321,8 @@ $facet-background-color: #007db8; text-shadow: none; //STATE: hover, focus - &:hover, &:focus { + &:hover, + &:focus { background: $m-blue-d5; } } @@ -463,7 +467,8 @@ $facet-background-color: #007db8; content: ""; } - .header-search-facets, .header-facet { + .header-search-facets, + .header-facet { @extend %t-title6; @extend %t-strong; @@ -579,7 +584,8 @@ $facet-background-color: #007db8; // +All Other Styles // ==================== -.find-courses, .university-profile { +.find-courses, +.university-profile { background: $gray-l5; padding-bottom: ($baseline*3); @@ -591,6 +597,5 @@ $facet-background-color: #007db8; border-top: 1px solid $border-color-2; margin-top: $baseline; padding-top: ($baseline*3); - } } diff --git a/lms/static/sass/multicourse/_help.scss b/lms/static/sass/multicourse/_help.scss index 8fe5295b64..bbcf0a9764 100644 --- a/lms/static/sass/multicourse/_help.scss +++ b/lms/static/sass/multicourse/_help.scss @@ -3,7 +3,7 @@ @include clearfix(); nav.categories { - border: 1px solid rgb(220,220,220); + border: 1px solid rgb(220, 220, 220); @include box-sizing(border-box); @include float(left); @@ -20,8 +20,9 @@ @include padding(12px, 0, 12px, 20px); @include text-align(left); - &:hover, &:focus { - background: rgb(245,245,245); + &:hover, + &:focus { + background: rgb(245, 245, 245); text-decoration: none; } } @@ -40,7 +41,7 @@ } > h2 { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid rgb(220, 220, 220); margin-bottom: ($baseline*2); padding-bottom: $baseline; } diff --git a/lms/static/sass/multicourse/_home.scss b/lms/static/sass/multicourse/_home.scss index 269e256956..a1f9a0c138 100644 --- a/lms/static/sass/multicourse/_home.scss +++ b/lms/static/sass/multicourse/_home.scss @@ -54,7 +54,8 @@ $course-search-input-height: ($button-size); vertical-align: top; // STATE: hover and focus - &:hover, &:focus { + &:hover, + &:focus { .actions { display: none; } @@ -152,7 +153,8 @@ $course-search-input-height: ($button-size); text-shadow: none; // STATE: hover and focus - &:hover, &:focus { + &:hover, + &:focus { background: $m-blue-l1; } } @@ -181,7 +183,8 @@ $course-search-input-height: ($button-size); width: flex-grid(2) + flex-gutter(); z-index: 2; - &:hover, &:focus { + &:hover, + &:focus { text-decoration: underline; } @@ -239,7 +242,8 @@ $course-search-input-height: ($button-size); } } - &:hover, &:focus { + &:hover, + &:focus { .play-intro { @include background-image(linear-gradient(-90deg, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.8))); @@ -416,7 +420,8 @@ $course-search-input-height: ($button-size); @include transition(all 0.15s ease-in-out 0s); - &:hover, &:focus { + &:hover, + &:focus { color: $lighter-base-font-color; } } @@ -431,7 +436,8 @@ $course-search-input-height: ($button-size); z-index: 2; } - &:hover, &:focus { + &:hover, + &:focus { text-decoration: none; &::before { @@ -478,10 +484,11 @@ $course-search-input-height: ($button-size); } .name > span { - font-size: 1.0em; + font-size: 1em; } - &:hover, &:focus { + &:hover, + &:focus { .name { bottom: 14px; } @@ -571,7 +578,8 @@ $course-search-input-height: ($button-size); width: flex-grid(4); - &:hover, &:focus { + &:hover, + &:focus { background: $body-bg; border: 1px solid $border-color-2; box-shadow: inset 0 0 3px 0 $shadow-l1; @@ -614,7 +622,8 @@ $course-search-input-height: ($button-size); color: $body-color; font: 700 1em/1.2em $font-family-sans-serif; - &:hover, &:focus { + &:hover, + &:focus { color: $blue; text-decoration: underline; } @@ -649,7 +658,8 @@ $course-search-input-height: ($button-size); color: lighten($body-color, 50%); - &:hover, &:focus { + &:hover, + &:focus { color: $blue; text-decoration: underline; } diff --git a/lms/static/sass/shared-v2/_layouts.scss b/lms/static/sass/shared-v2/_layouts.scss index e781285a79..7a7d5b9b78 100644 --- a/lms/static/sass/shared-v2/_layouts.scss +++ b/lms/static/sass/shared-v2/_layouts.scss @@ -2,7 +2,12 @@ .content-wrapper { max-width: map-get($container-max-widths, xl); - padding-bottom: $baseline*2; + margin-top: $baseline; + padding: 0 0 $baseline/2; + + @include media-breakpoint-up(md) { + padding: 0 $baseline $baseline/2; + } .page-content-container { @include clearfix(); @@ -39,21 +44,23 @@ display: inline-block; } - .page-header-secondary { - @include float(right); - @include text-align(right); + @include media-breakpoint-up(md) { + .page-header-secondary { + @include float(right); + @include text-align(right); - display: flex; - vertical-align: text-bottom; + display: flex; + vertical-align: text-bottom; - .form-actions { - @include margin-left($baseline/2); + .form-actions { + @include margin-left($baseline/2); - display: inline-block; - } + display: inline-block; + } - .form-actions > *:first-child { - @include margin-left(0); + .form-actions > *:first-child { + @include margin-left(0); + } } } } diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 190ab6bf33..ed2fdbc320 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -652,7 +652,8 @@ border-color: $m-blue-d1; } - .step-number, .step-name { + .step-number, + .step-name { color: $m-gray-d3; } } @@ -874,7 +875,7 @@ @include float(left); - width: flex-grid(4,12); + width: flex-grid(4, 12); @include text-align(right); @@ -1163,7 +1164,8 @@ } } - .contribution-option-other1 label, .contribution-option-other2 label { + .contribution-option-other1 label, + .contribution-option-other2 label { @extend %text-sr; } } @@ -1196,7 +1198,8 @@ } // previously defined in HTML - video, canvas { + video, + canvas { position: relative; display: block; @@ -1302,7 +1305,9 @@ margin-right: ($baseline/4); } - .deco-denomination, .label-value, .denomination-name { + .deco-denomination, + .label-value, + .denomination-name { display: inline-block; vertical-align: middle; } @@ -1443,7 +1448,8 @@ margin-bottom: 0; } - .wrapper-copy, .list-actions { + .wrapper-copy, + .list-actions { display: inline-block; vertical-align: middle; } @@ -1784,7 +1790,7 @@ .placeholder-art { position: relative; display: inline-block; - margin: $baseline 0 ($baseline/2) 0; + margin: $baseline 0 ($baseline/2); padding: $baseline; background: $verified-color-lvl3; border-radius: ($baseline*10); @@ -1844,7 +1850,8 @@ padding: ($baseline/2) $baseline; } - .copy-super, .copy-sub { + .copy-super, + .copy-sub { display: block; } @@ -1905,11 +1912,6 @@ } } - // VIEW: take and review photos - &.step-photos { - - } - // VIEW: take cam photo &.step-photos-cam { @@ -1967,7 +1969,8 @@ border-color: $verified-color-lvl3; } - .step-number, .step-name { + .step-number, + .step-name { color: $m-gray-l3; } } @@ -2014,7 +2017,8 @@ color: $m-blue-d3; - &:hover, &:focus { + &:hover, + &:focus { color: $m-blue-d1; border: none; } @@ -2043,7 +2047,7 @@ margin-top: ($baseline/2); } - .action-verify label { + .action-verify label { @extend %t-copy-sub1; } } @@ -2328,7 +2332,8 @@ border-color: $m-blue-d1; } - .step-number, .step-name { + .step-number, + .step-name { color: $m-gray-d3; } } @@ -2383,14 +2388,15 @@ border-color: $m-blue-d1; } - .step-number, .step-name { + .step-number, + .step-name { color: $m-gray-d3; } } } .progress-sts-value { - width: 0% !important; + width: 0 !important; } } @@ -2423,7 +2429,8 @@ border-color: $m-blue-d1; } - .step-number, .step-name { + .step-number, + .step-name { color: $m-gray-d3; } } @@ -2448,7 +2455,8 @@ border-color: $verified-color-lvl3; } - .step-number, .step-name { + .step-number, + .step-name { color: $m-gray-l3; } } @@ -2462,7 +2470,8 @@ border-color: $m-blue-d1; } - .step-number, .step-name { + .step-number, + .step-name { color: $m-gray-d3; } } diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html index 443232384e..b7e4f0432d 100644 --- a/lms/templates/courseware/course_navigation.html +++ b/lms/templates/courseware/course_navigation.html @@ -34,7 +34,7 @@ if course is not None: tab_list = get_course_tab_list(request, course) %> % if uses_bootstrap: -