diff --git a/.gitignore b/.gitignore
index f50ad217f3..fa417911e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,3 +131,6 @@ lms/lib/comment_client/python
autodeploy.properties
.ws_migrations_complete
dist
+
+# Visual Studio Code
+.vscode
diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py
index 296b53bb61..8d66a64ff6 100644
--- a/cms/djangoapps/contentstore/tests/test_course_listing.py
+++ b/cms/djangoapps/contentstore/tests/test_course_listing.py
@@ -318,7 +318,7 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin):
)
@ddt.data(
- (ModuleStoreEnum.Type.split, 3, 13),
+ (ModuleStoreEnum.Type.split, 4, 23),
(ModuleStoreEnum.Type.mongo, USER_COURSES_COUNT, 2)
)
@ddt.unpack
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index f306c6f9ea..87904bc8fc 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -1547,22 +1547,27 @@ def group_configurations_list_handler(request, course_key_string):
all_partitions = GroupConfiguration.get_all_user_partition_details(store, course)
should_show_enrollment_track = False
- group_schemes = []
+ has_content_groups = False
+ displayable_partitions = []
for partition in all_partitions:
- group_schemes.append(partition['scheme'])
- if partition['scheme'] == ENROLLMENT_SCHEME:
- enrollment_track_configuration = partition
- should_show_enrollment_track = len(enrollment_track_configuration['groups']) > 1
+ if partition['scheme'] == COHORT_SCHEME:
+ has_content_groups = True
+ displayable_partitions.append(partition)
+ elif partition['scheme'] == ENROLLMENT_SCHEME:
+ should_show_enrollment_track = len(partition['groups']) > 1
- # Remove the enrollment track partition and add it to the front of the list if it should be shown.
- all_partitions.remove(partition)
+ # Add it to the front of the list if it should be shown.
if should_show_enrollment_track:
- all_partitions.insert(0, partition)
+ displayable_partitions.insert(0, partition)
+ elif partition['scheme'] != RANDOM_SCHEME:
+ # Experiment group configurations are handled explicitly above. We don't
+ # want to display their groups twice.
+ displayable_partitions.append(partition)
# Add empty content group if there is no COHORT User Partition in the list.
# This will add ability to add new groups in the view.
- if COHORT_SCHEME not in group_schemes:
- all_partitions.append(GroupConfiguration.get_or_create_content_group(store, course))
+ if not has_content_groups:
+ displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course))
return render_to_response('group_configurations.html', {
'context_course': course,
@@ -1570,7 +1575,7 @@ def group_configurations_list_handler(request, course_key_string):
'course_outline_url': course_outline_url,
'experiment_group_configurations': experiment_group_configurations,
'should_show_experiment_groups': should_show_experiment_groups,
- 'all_group_configurations': all_partitions,
+ 'all_group_configurations': displayable_partitions,
'should_show_enrollment_track': should_show_enrollment_track
})
elif "application/json" in request.META.get('HTTP_ACCEPT'):
diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
index ac0b234c68..1ce1980465 100644
--- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
+++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
@@ -250,6 +250,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
Basic check that the groups configuration page responds correctly.
"""
+ # This creates a random UserPartition.
self.course.user_partitions = [
UserPartition(0, 'First name', 'First description', [Group(0, 'Group A'), Group(1, 'Group B'), Group(2, 'Group C')]),
]
@@ -261,7 +262,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
response = self.client.get(self._url())
self.assertEqual(response.status_code, 200)
- self.assertContains(response, 'First name')
+ self.assertContains(response, 'First name', count=1)
self.assertContains(response, 'Group C')
self.assertContains(response, CONTENT_GROUP_CONFIGURATION_NAME)
diff --git a/cms/envs/bok_choy.auth.json b/cms/envs/bok_choy.auth.json
index 44eac070f6..3adba23375 100644
--- a/cms/envs/bok_choy.auth.json
+++ b/cms/envs/bok_choy.auth.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CELERY_BROKER_PASSWORD": "celery",
diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json
index eb613edafc..320e0cdcc0 100644
--- a/cms/envs/bok_choy.env.json
+++ b/cms/envs/bok_choy.env.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_SERVER_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",
diff --git a/cms/envs/bok_choy_docker.auth.json b/cms/envs/bok_choy_docker.auth.json
index 6cc9885c45..b70bd46ed1 100644
--- a/cms/envs/bok_choy_docker.auth.json
+++ b/cms/envs/bok_choy_docker.auth.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CELERY_BROKER_PASSWORD": "celery",
diff --git a/cms/envs/bok_choy_docker.env.json b/cms/envs/bok_choy_docker.env.json
index 6e49423f36..905ca64104 100644
--- a/cms/envs/bok_choy_docker.env.json
+++ b/cms/envs/bok_choy_docker.env.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_SERVER_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",
diff --git a/cms/envs/common.py b/cms/envs/common.py
index c6aaad60f8..2d8c46cc96 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -710,6 +710,10 @@ base_vendor_js = [
'edx-ui-toolkit/js/utils/string-utils.js',
'edx-ui-toolkit/js/utils/html-utils.js',
+ # Load Bootstrap and supporting libraries
+ 'common/js/vendor/tether.js',
+ 'common/js/vendor/bootstrap.js',
+
# Finally load RequireJS
'common/js/vendor/require.js'
]
diff --git a/cms/static/sass/_build-v1.scss b/cms/static/sass/_build-v1.scss
index b62a4f855d..e2efd973e5 100644
--- a/cms/static/sass/_build-v1.scss
+++ b/cms/static/sass/_build-v1.scss
@@ -10,7 +10,7 @@
// +Base - Utilities
// ====================
-@import 'partials/variables';
+@import 'cms/base/variables';
@import 'mixins';
@import 'mixins-inherited';
diff --git a/cms/static/sass/_build-v2.scss b/cms/static/sass/_build-v2.scss
index 0aecd11645..056d171968 100644
--- a/cms/static/sass/_build-v2.scss
+++ b/cms/static/sass/_build-v2.scss
@@ -6,10 +6,13 @@
// Configuration
@import 'config';
-// Extensions
-@import 'partials/variables';
+// +Base - Utilities
+// ====================
+@import 'partials/cms/base/variables';
@import 'mixins-v2';
@import 'base-v2';
+
+// Pattern Library styling
@import 'elements-v2/controls';
@import 'elements-v2/header';
@import 'elements-v2/navigation';
diff --git a/cms/static/sass/bootstrap/_base.scss b/cms/static/sass/bootstrap/_base.scss
new file mode 100644
index 0000000000..95c50d513d
--- /dev/null
+++ b/cms/static/sass/bootstrap/_base.scss
@@ -0,0 +1,11 @@
+// Open edX: Studio base styles
+// ============================
+//
+// Note: these styles replicate the Studio styles directly
+// rather than benefiting from any Bootstrap classes. Ideally
+// the code base should be rebuilt using Bootstrap and then
+// these styles will no longer be necessary.
+
+.is-hidden {
+ display: none;
+}
diff --git a/cms/static/sass/bootstrap/_components.scss b/cms/static/sass/bootstrap/_components.scss
new file mode 100644
index 0000000000..618b9b7b68
--- /dev/null
+++ b/cms/static/sass/bootstrap/_components.scss
@@ -0,0 +1,195 @@
+// Open edX: components
+// ====================
+
+// Skip nav
+.nav-skip,
+.transcript-skip {
+ font-size: 14px;
+ line-height: 14px;
+ display: inline-block;
+ position: absolute;
+ left: 0;
+ top: -($baseline*30);
+ overflow: hidden;
+ background: $white;
+ border-bottom: 1px solid $gray-lightest;
+ padding: ($baseline*0.75) ($baseline/2);
+
+ &:focus,
+ &:active {
+ position: relative;
+ top: auto;
+ width: auto;
+ height: auto;
+ margin: 0;
+ }
+}
+
+// Page banner
+.page-banner {
+ max-width: $studio-max-width;
+ margin: 0 auto;
+
+ .user-messages {
+ margin-top: $baseline;
+ }
+}
+
+// Alerts
+.alert {
+ .icon-alert {
+ margin-right: $baseline / 4;
+ }
+}
+
+// Sock
+.wrapper-sock {
+ @include clearfix();
+ position: relative;
+ margin: ($baseline*2) 0 0 0;
+ border-top: 1px solid $gray-light;
+ width: 100%;
+
+ .wrapper-inner {
+ display: none;
+ width: 100% !important;
+ border-bottom: 1px solid $white;
+ padding: 0 $baseline !important;
+ }
+
+ // sock - actions
+ .list-cta {
+ @extend %ui-depth1;
+ position: absolute;
+ top: -($baseline*0.75);
+ width: 100%;
+ margin: 0 auto;
+ text-align: center;
+ list-style: none;
+
+ .cta-show-sock {
+ @extend %ui-btn-pill;
+ @extend %t-action4;
+ background: $gray-lightest;
+ padding: ($baseline/2) $baseline;
+ color: $gray-light;
+
+ .icon {
+ margin-right: $baseline/4;
+ }
+
+ &:hover {
+ background: $brand-primary;
+ color: $white;
+ }
+ }
+ }
+
+ // sock - additional help
+ .sock {
+ @include clearfix();
+ @extend %t-copy-sub2;
+ max-width: $studio-max-width;
+ width: flex-grid(12);
+ margin: 0 auto;
+ padding: ($baseline*2) 0;
+ color: $gray-light;
+
+ // support body
+ header {
+
+ .title {
+ @extend %t-title4;
+ }
+
+ }
+
+ .list-actions {
+ list-style: none;
+ }
+
+ // shared elements
+ .support, .feedback {
+ .title {
+ @extend %t-title6;
+ color: $white;
+ margin-bottom: ($baseline/2);
+ }
+
+ .copy {
+ @extend %t-copy-sub2;
+ margin: 0 0 $baseline 0;
+ }
+
+ .list-actions {
+ @include clearfix();
+
+ .action-item {
+ float: left;
+ margin-right: $baseline/2;
+ margin-bottom: ($baseline/2);
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ .action {
+ @extend %t-action4;
+ display: block;
+
+ .icon {
+ @extend %t-icon4;
+ vertical-align: middle;
+ margin-right: $baseline/4;
+ }
+
+ &:hover, &:active {
+
+ }
+ }
+
+ .tip {
+ @extend %cont-text-sr;
+ }
+ }
+
+ .action-primary {
+ @extend %btn-primary-blue;
+ @extend %t-action3;
+ }
+ }
+ }
+
+ // studio support content
+ .support {
+ width: flex-grid(8,12);
+ float: left;
+ margin-right: flex-gutter();
+
+ .action-item {
+ width: flexgrid(4,8);
+ }
+ }
+
+ // studio feedback content
+ .feedback {
+ width: flex-grid(4,12);
+ float: left;
+
+ .action-item {
+ width: flexgrid(4,4);
+ }
+ }
+ }
+
+ // case: sock content is shown
+ &.is-shown {
+ border-color: $gray-dark;
+
+ .list-cta .cta-show-sock {
+ background: $gray-dark;
+ border-color: $gray-dark;
+ color: $white;
+ }
+ }
+}
diff --git a/cms/static/sass/bootstrap/_footer.scss b/cms/static/sass/bootstrap/_footer.scss
new file mode 100644
index 0000000000..bcbbd57cb6
--- /dev/null
+++ b/cms/static/sass/bootstrap/_footer.scss
@@ -0,0 +1,96 @@
+// Open edX: Studio footer
+// =======================
+//
+// Note: these styles replicate the Studio styles directly
+// rather than benefiting from any Bootstrap classes. Ideally
+// the header should be reimagined using Bootstrap and then
+// these styles will no longer be necessary.
+
+.wrapper-footer {
+ position: relative;
+ width: 100%;
+ margin: 0 0 $baseline 0;
+ padding: $baseline;
+
+ footer.primary {
+ @extend %t-copy-sub2;
+ @include clearfix();
+ max-width: $studio-max-width;
+ width: flex-grid(12);
+ margin: 0 auto;
+ color: $gray-light;
+
+ .footer-content-primary {
+ @include clearfix();
+ }
+
+ .colophon {
+ width: flex-grid(4, 12);
+ float: left;
+ margin-right: flex-gutter(2);
+ }
+
+ .nav-peripheral {
+ width: flex-grid(6, 12);
+ float: right;
+ text-align: right;
+
+ .nav-item {
+ display: inline-block;
+ margin-right: $baseline/4;
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ a {
+ border-radius: 2px;
+ padding: ($baseline/2) ($baseline/2);
+ background: transparent;
+
+ .icon {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: $baseline/4;
+ }
+ }
+ }
+ }
+
+ .footer-content-secondary {
+ @include clearfix();
+ margin-top: $baseline;
+ }
+
+ .footer-about-copyright, .footer-about-openedx {
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ // platform trademarks
+ .footer-about-copyright {
+ width: flex-grid(4, 12);
+ float: left;
+ margin-right: flex-gutter(2);
+ }
+
+ // platform Open edX logo and link
+ .footer-about-openedx {
+ float: right;
+ text-align: right;
+
+ a {
+ display: inline-block;
+
+ img {
+ display: block;
+ width: ($baseline* 6);
+ }
+
+ &:hover {
+ border-bottom: none;
+ }
+ }
+ }
+ }
+}
diff --git a/cms/static/sass/bootstrap/_header.scss b/cms/static/sass/bootstrap/_header.scss
new file mode 100644
index 0000000000..adac9cf019
--- /dev/null
+++ b/cms/static/sass/bootstrap/_header.scss
@@ -0,0 +1,568 @@
+// Open edX: Studio header
+// =======================
+//
+// Note: these styles replicate the Studio styles directly
+// rather than benefiting from any Bootstrap classes. Ideally
+// the header should be reimagined using Bootstrap and then
+// these styles will no longer be necessary.
+
+// studio - elements - global header
+// ====================
+
+.wrapper-header {
+ @extend %ui-depth3;
+ position: relative;
+ width: 100%;
+ box-shadow: 0 1px 2px 0 $shadow-l1;
+ margin: 0;
+ padding: 0 $baseline;
+ background: $white;
+
+ header.primary {
+ @include clearfix();
+ max-width: $studio-max-width;
+ width: flex-grid(12);
+ margin: 0 auto;
+ }
+
+ // ====================
+
+ // basic layout
+ .wrapper-l, .wrapper-r {
+ background: $white;
+ }
+
+ .wrapper-l {
+ float: left;
+ width: flex-grid(7,12);
+ }
+
+ .wrapper-r {
+ float: right;
+ width: flex-grid(4,12);
+ text-align: right;
+ }
+
+ .branding, .info-course, .nav-course, .nav-account, .nav-pitch {
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ .user-language-selector {
+ width: 120px;
+ display: inline-block;
+ margin: 0 10px 0 5px;
+ vertical-align: sub;
+
+ .language-selector {
+ width: 120px;
+ }
+ }
+
+ .nav-account {
+ width: auto;
+ }
+
+ // basic layout - nav items
+ nav {
+
+ > ol > .nav-item {
+ @extend %t-action3;
+ @extend %t-strong;
+ display: inline-block;
+ vertical-align: middle;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ .nav-item a {
+ color: $gray;
+
+ &:hover {
+ color: $link-hover-color;
+ }
+ }
+ }
+
+ // basic layout - dropdowns
+ .nav-dd {
+
+ .title {
+ @extend %t-action2;
+ @extend %ui-btn-dd-nav-primary;
+
+ .label, .fa-caret-down {
+
+ }
+
+ .label {
+
+ }
+
+ .fa-caret-down {
+ opacity: 0.25;
+ }
+
+ &:hover {
+
+ .fa-caret-down {
+ opacity: 1.0;
+ }
+ }
+
+ .nav-sub .nav-item {
+
+ .icon {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: ($baseline/4);
+ }
+ }
+ }
+ }
+
+ // ====================
+
+ // specific elements - branding
+ .branding {
+ padding: ($baseline*0.75) 0;
+
+ a {
+ display: block;
+
+ img {
+ max-height: ($baseline*2);
+ display: block;
+ }
+ }
+ }
+
+ // ====================
+
+ // specific elements - course name/info
+ .info-course {
+ margin-right: flex-gutter();
+ border-right: 1px solid $gray-lighter;
+ padding: ($baseline*0.75) flex-gutter() ($baseline*0.75) 0;
+
+ .course-org, .course-number {
+ font-size: 12px;
+ line-height: 12px;
+ display: inline-block;
+ vertical-align: middle;
+ max-width: 45%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ opacity: 1.0;
+ color: $gray-dark;
+ }
+
+ .course-org {
+ margin-right: $baseline/4;
+ }
+
+ .course-title {
+ @extend %t-action2;
+ @extend %t-strong;
+ display: block;
+ width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ // entire link
+ .course-link {
+ display: block;
+ color: $gray-dark;
+
+ &:hover {
+ color: $link-hover-color;
+ }
+ }
+ }
+
+ // ====================
+
+ // specific elements - course nav
+ .nav-course {
+ padding: ($baseline*0.75) 0;
+ }
+
+ // ====================
+
+ // specific elements - account-based nav
+ .nav-account {
+ position: relative;
+ padding: ($baseline*0.75) 0;
+
+ .nav-sub {
+ text-align: left;
+ }
+
+ .nav-account-help {
+
+ .wrapper-nav-sub {
+ width: ($baseline*10);
+ }
+ }
+
+ .nav-account-user {
+
+ .title {
+ max-width: ($baseline*6.5);
+
+ > .label {
+ display: inline-block;
+ max-width: 84%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+
+ .settings-language-form {
+ margin-top: 4px;
+ .language-selector {
+ width: 130px;
+ }
+ }
+ }
+
+ // ====================
+
+ // specific elements - pitch/how it works nav
+ .nav-pitch {
+ position: relative;
+ padding: ($baseline*0.75) 0;
+
+ .nav-sub {
+ text-align: left;
+ }
+ }
+}
+
+// ====================
+
+// CASE: user signed in
+.is-signedin {
+
+ .wrapper-l {
+ width: flex-grid(8,12);
+ }
+
+ .wrapper-r {
+ width: flex-grid(4,12);
+ }
+
+ .branding {
+ margin-right: 2%;
+ }
+
+ .nav-account {
+ top: ($baseline/4);
+ }
+}
+
+// ====================
+
+// CASE: in course {
+.is-signedin.course {
+
+ .wrapper-header {
+
+ .wrapper-l {
+ width: flex-grid(9,12);
+ }
+
+ .wrapper-r {
+ width: flex-grid(3,12);
+ }
+
+ .branding {
+ margin-right: 2%;
+ }
+
+ .info-course {
+ width: 25%;
+ margin-right: 2%;
+ }
+
+ .nav-course {
+ width: 45%;
+ }
+ }
+}
+
+// ====================
+
+// CASE: user not signed in
+.not-signedin,
+.view-util {
+
+ .wrapper-header {
+
+ .wrapper-l {
+ width: flex-grid(2,12);
+ }
+
+ .wrapper-r {
+ width: flex-grid(10,12);
+ }
+
+ .branding {
+ width: 100%;
+ }
+
+ .nav-pitch {
+ top: ($baseline/4);
+
+ .nav-item {
+ margin-right: ($baseline/2);
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ .action-signup {
+ padding: ($baseline/4) ($baseline/2);
+ text-transform: uppercase;
+ }
+
+ .action-signin {
+ padding: ($baseline/4) ($baseline/2);
+ text-transform: uppercase;
+ }
+ }
+ }
+}
+
+// dropdown
+.nav-dd {
+
+ .title {
+
+ .label, .fa-caret-down {
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ .ui-toggle-dd {
+ margin: 0;
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ // dropped down state
+ &.is-selected {
+
+ .ui-toggle-dd {
+ transform: rotate(-180deg);
+ transform-origin: 50% 50%;
+ }
+ }
+ }
+
+ .nav-item {
+ position: relative;
+
+ &:hover {
+
+ }
+
+ &.nav-course-settings {
+ .wrapper-nav-sub {
+ width: ($baseline*9);
+ }
+ }
+ }
+
+ .wrapper-nav-sub {
+ position: absolute;
+ top: ($baseline*2.5);
+ opacity: 0.0;
+ pointer-events: none;
+ width: ($baseline*8);
+ overflow: hidden;
+ height: 0;
+
+
+ // dropped down state
+ &.is-shown {
+ opacity: 1.0;
+ pointer-events: auto;
+ overflow: visible;
+ height: auto;
+ }
+ }
+
+ .nav-sub {
+ border-radius: 2px;
+ box-shadow: 0 1px 1px $shadow-l1;
+ position: relative;
+ width: 100%;
+ border: 1px solid $gray-light;
+ padding: ($baseline/2) ($baseline*0.75);
+ background: $white;
+
+ &:after, &:before {
+ bottom: 100%;
+ border: solid transparent;
+ content: " ";
+ height: 0;
+ width: 0;
+ position: absolute;
+ pointer-events: none;
+ }
+
+ // ui triangle/nub
+ &:after {
+ border-color: rgba(255, 255, 255, 0);
+ border-bottom-color: $white;
+ border-width: 10px;
+ }
+
+ &:before {
+ border-color: rgba(178, 178, 178, 0);
+ border-bottom-color: $gray-light;
+ border-width: 11px;
+ }
+
+ .nav-item {
+ @extend %t-action3;
+ @extend %t-regular;
+ display: block;
+ margin: 0 0 ($baseline/4) 0;
+ border-bottom: 1px solid $gray-lighter;
+ padding: 0 0($baseline/4) 0;
+
+ &:last-child {
+ margin-bottom: 0;
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+
+ a {
+ display: block;
+
+ &:hover, &:active {
+ color: $brand-primary;
+ }
+ }
+ }
+ }
+
+ // CASE: left-hand side arrow/dd
+ &.ui-left {
+
+ .wrapper-nav-sub {
+ left: 0;
+ }
+
+ .nav-sub {
+ text-align: left;
+
+ // ui triangle/nub
+ &:after {
+ left: $baseline;
+ margin-left: -10px;
+ }
+
+ &:before {
+ left: $baseline;
+ margin-left: -11px;
+ }
+ }
+ }
+
+ // CASE: right-hand side arrow/dd
+ &.ui-right {
+
+ .wrapper-nav-sub {
+ left: none;
+ right: 0;
+ }
+
+ .nav-sub {
+
+ // ui triangle/nub
+ &:after {
+ right: $baseline;
+ margin-right: -10px;
+ }
+
+ &:before {
+ right: $baseline;
+ margin-right: -11px;
+ }
+ }
+ }
+}
+
+// ====================
+
+// STATE: active/current nav states
+
+.nav-item.is-current,
+body.howitworks .nav-not-signedin-hiw,
+
+// dashboard
+body.dashboard .nav-account-dashboard,
+
+// course content
+body.course.view-outline .nav-course-courseware .title,
+body.course.view-updates .nav-course-courseware .title,
+body.course.view-static-pages .nav-course-courseware .title,
+body.course.view-uploads .nav-course-courseware .title,
+body.course.view-textbooks .nav-course-courseware .title,
+body.course.view-video-uploads .nav-course-courseware .title,
+
+body.course.view-outline .nav-course-courseware-outline,
+body.course.view-updates .nav-course-courseware-updates,
+body.course.view-static-pages .nav-course-courseware-pages,
+body.course.view-uploads .nav-course-courseware-uploads,
+body.course.view-textbooks .nav-course-courseware-textbooks,
+body.course.view-video-uploads .nav-course-courseware-videos,
+
+// course settings
+body.course.schedule .nav-course-settings .title,
+body.course.grading .nav-course-settings .title,
+body.course.view-team .nav-course-settings .title,
+body.course.view-group-configurations .nav-course-settings .title,
+body.course.advanced .nav-course-settings .title,
+body.course.view-certificates .nav-course-settings .title,
+
+body.course.schedule .nav-course-settings-schedule,
+body.course.grading .nav-course-settings-grading,
+body.course.view-team .nav-course-settings-team,
+body.course.view-group-configurations .nav-course-settings-group-configurations,
+body.course.advanced .nav-course-settings-advanced,
+body.course.view-certificates .nav-course-settings-certificates,
+
+// course tools
+body.course.view-import .nav-course-tools .title,
+body.course.view-export .nav-course-tools .title,
+body.course.view-export-git .nav-course-tools .title,
+
+body.course.view-import .nav-course-tools-import,
+body.course.view-export .nav-course-tools-export,
+body.course.view-export-git .nav-course-tools-export-git,
+
+// content library settings
+body.course.view-team .nav-library-settings .title,
+
+body.course.view-team .nav-library-settings-team
+
+{
+ color: $brand-primary;
+
+ a {
+ color: $brand-primary;
+ pointer-events: none;
+ }
+}
diff --git a/cms/static/sass/bootstrap/_layouts.scss b/cms/static/sass/bootstrap/_layouts.scss
new file mode 100644
index 0000000000..debb3548d5
--- /dev/null
+++ b/cms/static/sass/bootstrap/_layouts.scss
@@ -0,0 +1,362 @@
+// Open edX: Studio layout
+// =======================
+//
+// Note: these styles replicate the Studio styles directly
+// rather than benefiting from any Bootstrap classes. Ideally
+// the layouts should be reimagined using Bootstrap and then
+// these styles will no longer be necessary.
+
+.content-wrapper {
+ margin-top: $baseline;
+
+ .course-tabs {
+ padding-bottom: 0;
+
+ .nav-item {
+ &.active, &:hover{
+ .nav-link {
+ border-bottom-color: $brand-primary;
+ color: $brand-primary;
+ }
+ }
+
+ .nav-link {
+ padding: $baseline/2 $baseline*3/4 $baseline*13/20;
+ border-style: solid;
+ border-width: 0 0 $baseline/5 0;
+ border-bottom-color: transparent;
+
+ @media (max-width: map-get($grid-breakpoints, md)) {
+ border: none;
+ text-align: left;
+ padding: 0 0 $baseline/2 0;
+ }
+ }
+ }
+ }
+
+ .main-container {
+ border: 1px solid $inverse-color;
+ background-color: $body-bg;
+
+ .page-header {
+ border-bottom: 1px solid $inverse-color;
+ padding: 20px;
+ }
+
+ .page-content {
+ padding: 20px;
+ }
+ }
+
+ &.container-fluid {
+ max-width: $studio-max-width;
+ }
+}
+
+// studio - elements - layouts
+// ====================
+
+// layout - basic
+// the wrapper around the viewable page area, excluding modal and other extra-view content
+.wrapper-view {
+
+}
+
+// ====================
+
+// layout - basic page header
+.wrapper-mast {
+ margin: ($baseline*1.5) 0 0 0;
+ padding: 0 $baseline;
+ position: relative;
+
+ .mast,
+ .metadata {
+ @include clearfix();
+ position: relative;
+ max-width: $studio-max-width;
+ width: flex-grid(12);
+ margin: 0 auto $baseline auto;
+ color: $gray-dark;
+ }
+
+ .mast {
+ border-bottom: 1px solid $gray-light;
+ padding-bottom: ($baseline/2);
+
+ // layout without actions
+ .page-header {
+ width: flex-grid(12);
+ }
+
+ // layout with actions
+ &.has-actions {
+ @include clearfix();
+
+ .page-header {
+ float: left;
+ width: flex-grid(6,12);
+ margin-right: flex-gutter();
+ }
+
+ .nav-actions {
+ position: relative;
+ bottom: -($baseline*0.75);
+ float: right;
+ width: flex-grid(6,12);
+ text-align: right;
+
+ .nav-item {
+ display: inline-block;
+ vertical-align: top;
+ margin-right: ($baseline/2);
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ // buttons
+ .button {
+ @extend %btn-primary-blue;
+ @extend %sizing;
+
+ .action-button-text {
+ display: inline-block;
+ vertical-align: baseline;
+ }
+
+ .icon {
+ display: inline-block;
+ vertical-align: baseline;
+ }
+
+ // CASE: new/create button
+ &.new-button,
+ &.button-new {
+ @extend %btn-primary-green;
+ @extend %sizing;
+ }
+ }
+ }
+ }
+
+ // layout with actions
+ &.has-subtitle {
+
+ .nav-actions {
+ bottom: -($baseline*1.5);
+ }
+ }
+
+ // layout with breadcrumb navigation
+ &.has-navigation {
+
+ .nav-actions {
+ bottom: -($baseline*1.5);
+ }
+
+ .navigation-item {
+ @extend %cont-truncated;
+ display: inline-block;
+ vertical-align: bottom; // correct for extra padding in FF
+ max-width: 250px;
+ color: $gray-dark;
+
+ &.navigation-current {
+ @extend %ui-disabled;
+ color: $gray;
+ max-width: 250px;
+
+ &:before {
+ color: $gray;
+ }
+ }
+ }
+
+ .navigation-link:hover {
+ color: $brand-primary;
+ }
+
+ .navigation-item:before {
+ content: " / ";
+ margin: ($baseline/4);
+ color: $gray;
+
+ &:hover {
+ color: $gray;
+ }
+ }
+
+ .navigation .navigation-item:first-child:before {
+ content: "";
+ margin: 0;
+ }
+ }
+ }
+
+ // CASE: wizard-based mast
+ .mast-wizard {
+
+ .page-header-sub {
+ @extend %t-title4;
+ color: $gray;
+ font-weight: 300;
+ }
+
+ .page-header-super {
+ @extend %t-title4;
+ float: left;
+ width: flex-grid(12,12);
+ margin-top: ($baseline/2);
+ border-top: 1px solid $gray-lighter;
+ padding-top: ($baseline/2);
+ font-weight: 600;
+ }
+ }
+
+ // page metadata/action bar
+ .metadata {
+
+ }
+}
+
+// layout - basic page content
+.wrapper-content {
+ margin: 0;
+ padding: 0 $baseline;
+ position: relative;
+}
+
+.content {
+ @include clearfix();
+ @extend %t-copy-base;
+ max-width: $studio-max-width;
+ width: flex-grid(12);
+ margin: 0 auto;
+ color: $gray-dark;
+
+ header {
+ position: relative;
+ margin-bottom: $baseline;
+ border-bottom: 1px solid $gray-lighter;
+ padding-bottom: ($baseline/2);
+
+ .title-sub {
+ @extend %t-copy-sub1;
+ display: block;
+ margin: 0;
+ color: $gray-light;
+ }
+
+ .title-1 {
+ @extend %t-title3;
+ @extend %t-strong;
+ margin: 0;
+ padding: 0;
+ color: $gray-dark;
+ }
+ }
+}
+
+// 3/4 - 1/4 two col layout
+%two-col-1 {
+ .content-primary {
+ float: left;
+ margin-right: flex-gutter();
+ width: flex-grid(9,12);
+ box-shadow: none;
+ border: 0;
+ background-color: $white;
+ }
+
+ .content-supplementary {
+ float: left;
+ width: flex-grid(3,12);
+ }
+}
+
+
+// layout - primary content
+.content-primary {
+
+ .title-1 {
+ @extend %t-title3;
+ }
+
+ .title-2 {
+ @extend %t-title4;
+ margin: 0 0 ($baseline/2) 0;
+ }
+
+ .title-3 {
+ @extend %t-title6;
+ margin: 0 0 ($baseline/2) 0;
+ }
+
+ header {
+ @include clearfix();
+
+ .title-2 {
+ width: flex-grid(5, 12);
+ margin: 0 flex-gutter() 0 0;
+ float: left;
+ }
+
+ .tip {
+ @extend %t-copy-sub2;
+ width: flex-grid(7, 12);
+ float: right;
+ margin-top: ($baseline/2);
+ text-align: right;
+ color: $gray-dark;
+ }
+ }
+}
+
+// layout - supplemental content
+.content-supplementary {
+
+ > section {
+ margin: 0 0 $baseline 0;
+ }
+}
+
+// ====================
+
+// layout - grandfathered
+.main-wrapper {
+ position: relative;
+ margin: 0 ($baseline*2);
+}
+
+.inner-wrapper {
+ @include clearfix();
+ position: relative;
+ max-width: 1280px;
+ margin: auto;
+
+ > article {
+ clear: both;
+ }
+}
+
+.main-column {
+ clear: both;
+ float: left;
+ width: 70%;
+}
+
+.sidebar {
+ float: right;
+ width: 28%;
+}
+
+.left {
+ float: left;
+}
+
+.right {
+ float: right;
+}
diff --git a/cms/static/sass/bootstrap/_mixins.scss b/cms/static/sass/bootstrap/_mixins.scss
new file mode 100644
index 0000000000..b21f0d7f77
--- /dev/null
+++ b/cms/static/sass/bootstrap/_mixins.scss
@@ -0,0 +1,239 @@
+// common - utilities - mixins and extends
+// ====================
+//
+// Note: these mixins replicate the Studio mixins directly
+// to simplify the usage of Studio Sass partials. They
+// should be deprecated in favor of using native Bootstrap
+// functionality.
+
+
+// Table of Contents
+// * +Font Sizing - Mixin
+// * +Line Height - Mixin
+// * +Sizing - Mixin
+// * +Square - Mixin
+// * +Placeholder Styling - Mixin
+// * +Flex Support - Mixin
+// * +Flex Polyfill - Extends
+// * +UI - Wrapper - Extends
+// * +UI - Window - Extend
+// * +UI - Visual Link - Extend
+// * +UI - Functional Disable - Extend
+// * +UI - Visual Link - Extend
+// * +UI - Depth Levels - Extends
+// * +UI - Clear Children - Extends
+// * +UI - Buttons - Extends
+// * +UI - Well Archetype - Extends
+// * +Content - No List - Extends
+// * +Content - Hidden Image Text - Extend
+// * +Content - Screenreader Text - Extend
+// * +Content - Text Wrap - Extend
+// * +Content - Text Truncate - Extend
+// * +Icon - Font-Awesome - Extend
+// * +Icon - SSO icon images
+
+// +Font Sizing - Mixin
+// ====================
+@mixin font-size($sizeValue: 16){
+ font-size: $sizeValue + px;
+ font-size: ($sizeValue/10) + rem;
+}
+
+// +Line Height - Mixin
+// ====================
+@mixin line-height($fontSize: auto){
+ line-height: ($fontSize*1.48) + px;
+ line-height: (($fontSize/10)*1.48) + rem;
+}
+
+// +Sizing - Mixin
+// ====================
+@mixin size($width: $baseline, $height: $baseline) {
+ height: $height;
+ width: $width;
+}
+
+// +Square - Mixin
+// ====================
+@mixin square($size: $baseline) {
+ @include size($size);
+}
+
+// +Placeholder Styling - Mixin
+// ====================
+@mixin placeholder($color) {
+ :-moz-placeholder {
+ color: $color;
+ }
+ ::-webkit-input-placeholder {
+ color: $color;
+ }
+ :-ms-input-placeholder {
+ color: $color;
+ }
+}
+
+// +Flex Support - Mixin
+// ====================
+@mixin ui-flexbox() {
+ display: -webkit-box;
+ display: -moz-box;
+ display: -ms-flexbox;
+ display: -webkit-flex;
+ display: flex;
+}
+
+// +Flex PolyFill - Extends
+// ====================
+
+// justify-content right for display:flex alignment in older browsers
+%ui-justify-right-flex {
+ -webkit-box-pack: flex-end;
+ -moz-box-pack: flex-end;
+ -ms-flex-pack: flex-end;
+ -webkit-justify-content: flex-end;
+ justify-content: flex-end;
+}
+
+// justify-content left for display:flex alignment in older browsers
+%ui-justify-left-flex {
+ -webkit-box-pack: flex-start;
+ -moz-box-pack: flex-start;
+ -ms-flex-pack: flex-start;
+ -webkit-justify-content: flex-start;
+ justify-content: flex-start;
+}
+
+// align items center for display:flex alignment in older browsers
+%ui-align-center-flex {
+ -webkit-flex-align: center;
+ -ms-flex-align: center;
+ -webkit-align-items: center;
+ align-items: center;
+}
+
+
+// +UI - Wrapper - Extends
+// ====================
+// used for page/view-level wrappers (for centering/grids)
+%ui-wrapper {
+ @include clearfix();
+ width: 100%;
+}
+
+// +UI - Depth Levels - Extends
+// ====================
+%ui-depth0 { z-index: 0; }
+%ui-depth1 { z-index: 10; }
+%ui-depth2 { z-index: 100; }
+%ui-depth3 { z-index: 1000; }
+%ui-depth4 { z-index: 10000; }
+%ui-depth5 { z-index: 100000; }
+
+
+// +UI - Clear Children - Extends
+// ====================
+// extends - UI - utility - first child clearing
+%wipe-first-child {
+
+ &:first-child {
+ margin-top: 0;
+ border-top: none;
+ padding-top: 0;
+ }
+}
+
+// extends - UI - utility - last child clearing
+%wipe-last-child {
+
+ &:last-child {
+ margin-bottom: 0;
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+}
+
+// +Content - No List - Extends
+// ====================
+// removes list styling/spacing when using uls, ols for navigation and less content-centric cases
+%cont-no-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ text-indent: 0;
+
+ li {
+ margin: 0;
+ padding: 0;
+ }
+}
+
+// +Content - Hidden Image Text - Extend
+// ====================
+// image-replacement hidden text
+%cont-text-hide {
+ text-indent: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+// +Content - Screenreader Text - Extend
+// ====================
+%cont-text-sr {
+ // clip has been deprecated but is still supported
+ clip: rect(1px 1px 1px 1px);
+ clip: rect(1px, 1px, 1px, 1px);
+ position: absolute;
+ margin: -1px;
+ height: 1px;
+ width: 1px;
+ border: 0;
+ padding: 0;
+ overflow: hidden;
+ // ensure there are spaces in sr text
+ word-wrap: normal;
+}
+
+// +Content - Text Wrap - Extend
+// ====================
+%cont-text-wrap {
+ word-wrap: break-word;
+}
+
+// +Content - Text Truncate - Extend
+// ====================
+%cont-truncated {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+// * +Icon - Font-Awesome - Extend
+// ====================
+%use-font-awesome {
+ display: inline-block;
+ font-family: FontAwesome;
+ -webkit-font-smoothing: antialiased;
+ speak: none;
+}
+
+%btn-no-style {
+ background: transparent;
+ border: 0;
+ padding: 0;
+ margin: 0;
+}
+
+
+// * +Icon - SSO icon images
+// ====================
+
+%sso-icon {
+ .icon-image {
+ width: auto;
+ height: auto;
+ max-height: 1.4em;
+ max-width: 1.4em;
+ margin-top: -2px;
+ }
+}
diff --git a/cms/static/sass/bootstrap/_navigation.scss b/cms/static/sass/bootstrap/_navigation.scss
new file mode 100644
index 0000000000..a45ae4c008
--- /dev/null
+++ b/cms/static/sass/bootstrap/_navigation.scss
@@ -0,0 +1,95 @@
+// Local overrides for bootstrap navigation bar theming
+.navigation-container {
+ border-bottom: 2px solid $brand-primary;
+ text-decoration: none;
+ background-color: $header-bg;
+
+ &.slim {
+ border-bottom: 1px solid $inverse-color;
+ box-shadow: 0 1px 5px 0 $black-t0;
+ }
+
+ .navbar {
+ margin: 0 auto;
+ max-width: map-get($container-max-widths, xl);
+
+ .logo.slim a {
+ height: $baseline*3/2;
+ margin-top: $baseline/5;
+ }
+
+ .course-header {
+ font-size: $font-size-lg;
+ margin: $baseline/2 $baseline/2 0 0;
+
+ .provider {
+ font-weight: $font-weight-bold;
+ }
+ }
+
+ .nav-item {
+ margin: 0 $baseline 0 0;
+ font-weight: $font-weight-normal;
+ font-family: $font-family-base;
+ text-transform: uppercase;
+ list-style: none;
+
+ .nav-link {
+ color: $brand-primary;
+ }
+
+ .user-image-frame {
+ max-width: $baseline*2;
+ border-radius: $border-radius;
+ }
+
+ // Dealing with creating a collapsed menu
+ &.nav-item-open-collapsed-only {
+ display: none;
+ }
+
+ @media (max-width: map-get($grid-breakpoints,md)) {
+ &.nav-item-open-collapsed, &.nav-item-open-collapsed-only {
+ display: initial;
+ margin: $baseline/4 $baseline/2;
+
+ a {
+ color: $brand-primary;
+ padding: 0;
+ text-decoration: none;
+
+ &:hover {
+ color: $input-border-color;
+ }
+ }
+ }
+ &.nav-item-hidden-collapsed {
+ display: none;
+ }
+ }
+ }
+
+ .btn-shopping-cart{
+ padding-top: 0.7rem; // $btn-padding-y-lg once themed
+ }
+
+ .navbar-right .nav-item {
+ @media (min-width: map-get($grid-breakpoints,md)) {
+ .nav-link {
+ text-transform: none;
+ color: $brand-inverse;
+ font-weight: $font-weight-bold;
+ cursor: pointer;
+ }
+ }
+
+ &.dropdown {
+ cursor: pointer;
+
+ .dropdown-item {
+ text-transform: initial;
+ }
+ }
+ }
+ }
+}
diff --git a/cms/static/sass/bootstrap/_variables.scss b/cms/static/sass/bootstrap/_variables.scss
new file mode 100644
index 0000000000..617175a4f0
--- /dev/null
+++ b/cms/static/sass/bootstrap/_variables.scss
@@ -0,0 +1,71 @@
+// Studio - bootstrap utilities - variables
+// ========================================
+
+// #Units: Unit variables
+// #GRID: Grid and layout variables
+// #COLORS: Base, palette and theme color definitions + application
+// #TYPOGRAPHY: Font definitions and scales
+// #ICONS: Icon specific colors + other styling
+
+// ----------------------------
+// #UNITS
+// ----------------------------
+$baseline: 20px !default;
+
+// ----------------------------
+// #GRID
+// ----------------------------
+$studio-max-width: 1180px !default;
+
+// ----------------------------
+// #COLORS
+// ----------------------------
+
+$studio-gray: palette(grayscale, base) !default;
+$studio-background-color: palette(grayscale, x-back) !default;
+$studio-container-background-color: $white !default;
+$studio-border-color: palette(grayscale, back) !default;
+$studio-label-color: palette(grayscale, black) !default;
+$studio-active-color: palette(primary, base) !default;
+$studio-preview-menu-color: #c8c8c8 !default;
+$success-color: palette(success, accent) !default;
+$success-color-hover: palette(success, text) !default;
+
+$button-bg-hover-color: $white !default;
+
+$white-transparent: rgba(255, 255, 255, 0) !default;
+$white-opacity-40: rgba(255, 255, 255, 0.4) !default;
+$white-opacity-60: rgba(255, 255, 255, 0.6) !default;
+$white-opacity-70: rgba(255, 255, 255, 0.7) !default;
+$white-opacity-80: rgba(255, 255, 255, 0.8) !default;
+
+$black: rgb(0,0,0) !default;
+$black-t0: rgba($black, 0.125) !default;
+$black-t1: rgba($black, 0.25) !default;
+$black-t2: rgba($black, 0.5) !default;
+$black-t3: rgba($black, 0.75) !default;
+
+$light-grey-transparent: rgba(200,200,200, 0) !default;
+$light-grey-solid: rgba(200,200,200, 1) !default;
+
+$header-bg: $white !default;
+$footer-bg: $white !default;
+
+// ----------------------------
+// #TYPOGRAPHY
+// ----------------------------
+$font-light: 300 !default;
+$font-regular: 400 !default;
+$font-semibold: 600 !default;
+$font-bold: 700 !default;
+
+// ----------------------------
+// #ICONS
+// ----------------------------
+// Icons
+$studio-dark-icon-color: $white !default;
+$studio-dark-icon-background-color: palette(grayscale, black) !default;
+
+$site-status-color: rgb(182,37,103) !default;
+
+$shadow-l1: rgba(0,0,0,0.1) !default;
diff --git a/cms/static/sass/bootstrap/studio-main.scss b/cms/static/sass/bootstrap/studio-main.scss
new file mode 100644
index 0000000000..de32b0257a
--- /dev/null
+++ b/cms/static/sass/bootstrap/studio-main.scss
@@ -0,0 +1,19 @@
+// -----------------------------
+// Studio main styles for Bootstrap
+// -----------------------------
+
+// Bootstrap theme
+@import 'bootstrap/theme';
+@import 'bootstrap/scss/bootstrap';
+
+// Variables
+@import 'mixins';
+@import 'variables';
+@import 'base';
+
+// Elements
+@import 'header';
+@import 'footer';
+@import 'navigation';
+@import 'layouts';
+@import 'components';
diff --git a/cms/static/sass/partials/bootstrap/_theme.scss b/cms/static/sass/partials/bootstrap/_theme.scss
new file mode 100644
index 0000000000..af3ef13da3
--- /dev/null
+++ b/cms/static/sass/partials/bootstrap/_theme.scss
@@ -0,0 +1,5 @@
+// Default bootstrap theming
+
+$body-bg: #f5f5f5 !default;
+
+@import 'edx-bootstrap/sass/open-edx/theme';
diff --git a/cms/static/sass/partials/_variables.scss b/cms/static/sass/partials/cms/base/_variables.scss
similarity index 100%
rename from cms/static/sass/partials/_variables.scss
rename to cms/static/sass/partials/cms/base/_variables.scss
diff --git a/cms/templates/base.html b/cms/templates/base.html
index f987897112..8e24b21021 100644
--- a/cms/templates/base.html
+++ b/cms/templates/base.html
@@ -44,7 +44,11 @@ from openedx.core.djangolib.js_utils import (
<%static:css group='style-vendor-tinymce-content'/>
<%static:css group='style-vendor-tinymce-skin'/>
- <%static:css group='${self.attr.main_css}'/>
+ % if uses_bootstrap:
+
+ % else:
+ <%static:css group='${self.attr.main_css}'/>
+ % endif
<%include file="widgets/segment-io.html" />
diff --git a/cms/templates/ux/reference/_note-usage.html b/cms/templates/ux/reference/_note-usage.html
deleted file mode 100644
index 2a5ebab8f5..0000000000
--- a/cms/templates/ux/reference/_note-usage.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/cms/templates/ux/reference/bootstrap/test.html b/cms/templates/ux/reference/bootstrap/test.html
new file mode 100644
index 0000000000..311a85a132
--- /dev/null
+++ b/cms/templates/ux/reference/bootstrap/test.html
@@ -0,0 +1,408 @@
+## Override the default styles_version to use Bootstrap
+<%! main_css = "css/bootstrap/studio-main.css" %>
+
+<%page expression_filter="h"/>
+
+<%!
+from openedx.core.djangoapps.util.user_messages import (
+ register_error_message,
+ register_info_message,
+ register_success_message,
+ register_warning_message,
+)
+%>
+
+<%
+register_info_message(request, _('This is a test message'))
+%>
+
+<%inherit file="/base.html" />
+<%block name="title">Bootstrap Test Page%block>
+<%block name="bodyclass">bootstrap-test%block>
+
+<%block name="content">
+
- UX Style Reference
+ UX Style Reference
-
-
-
-
-
-
+
diff --git a/cms/templates/ux/reference/pattern-library-test.html b/cms/templates/ux/reference/pattern-library/test.html
similarity index 100%
rename from cms/templates/ux/reference/pattern-library-test.html
rename to cms/templates/ux/reference/pattern-library/test.html
diff --git a/common/djangoapps/pipeline_mako/templates/static_content.html b/common/djangoapps/pipeline_mako/templates/static_content.html
index 2b23ddbb06..e59b2636e3 100644
--- a/common/djangoapps/pipeline_mako/templates/static_content.html
+++ b/common/djangoapps/pipeline_mako/templates/static_content.html
@@ -93,6 +93,7 @@ source, template_path = Loader(engine).load_template_source(path)
%doc>
<%
from django.template import Template, Context
+ from webpack_loader.exceptions import WebpackLoaderBadStatsError
try:
return Template("""
{% load render_bundle from webpack_loader %}
@@ -105,9 +106,9 @@ source, template_path = Loader(engine).load_template_source(path)
'entry': entry,
'body': capture(caller.body)
}))
- except IOError as e:
+ except (IOError, WebpackLoaderBadStatsError) as e:
# Don't break Mako template rendering if the bundle or webpack-stats can't be found, but log it
- logger.error(e)
+ logger.error('[LEARNER-1938] {error}'.format(error=e))
%>
%def>
diff --git a/common/djangoapps/status/models.py b/common/djangoapps/status/models.py
index 894891f1b1..a4eb3bf161 100644
--- a/common/djangoapps/status/models.py
+++ b/common/djangoapps/status/models.py
@@ -26,10 +26,10 @@ class GlobalStatusMessage(ConfigurationModel):
msg = self.message
if course_key:
try:
- course_message = self.coursemessage_set.get(course_key=course_key)
- # Don't add the message if course_message is blank.
- if course_message:
- msg = u"{}
{}".format(msg, course_message.message)
+ course_home_message = self.coursemessage_set.get(course_key=course_key)
+ # Don't add the message if course_home_message is blank.
+ if course_home_message:
+ msg = u"{}
{}".format(msg, course_home_message.message)
except CourseMessage.DoesNotExist:
# We don't have a course-specific message, so pass.
pass
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 56b98ebf73..e4799a44d4 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -734,13 +734,12 @@ def dashboard(request):
show_courseware_links_for = frozenset(
enrollment.course_id for enrollment in course_enrollments
if has_access(request.user, 'load', enrollment.course_overview)
- and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
)
# Find programs associated with course runs being displayed. This information
# is passed in the template context to allow rendering of program-related
# information on the dashboard.
- meter = ProgramProgressMeter(user, enrollments=course_enrollments)
+ meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
inverted_programs = meter.invert_programs()
# Construct a dictionary of course mode information
diff --git a/common/djangoapps/third_party_auth/middleware.py b/common/djangoapps/third_party_auth/middleware.py
index 1e5069e8fb..81e4d3de78 100644
--- a/common/djangoapps/third_party_auth/middleware.py
+++ b/common/djangoapps/third_party_auth/middleware.py
@@ -23,32 +23,3 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware):
redirect_uri = pipeline.AUTH_DISPATCH_URLS[auth_entry]
return redirect_uri
-
-
-class PipelineQuarantineMiddleware(object):
- """
- Middleware flushes the session if a user agent with a quarantined session
- attempts to leave the quarantined set of views.
- """
-
- def process_view(self, request, view_func, view_args, view_kwargs): # pylint: disable=unused-argument
- """
- Check the session to see if we've quarantined the user to a particular
- step of the authentication pipeline; if so, look up which modules the
- user is allowed to browse to without breaking the pipeline. If the view
- that's been requested is outside those modules, then flush the session.
-
- In general, this middleware should be used in cases where allowing the
- user to exit the running pipeline would be undesirable, and where it'd
- be better to flush the session state rather than allow it. Pipeline
- quarantining is utilized by the Enterprise application to enforce
- collection of user consent for sharing data with a linked third-party
- authentication provider.
- """
- if not pipeline.running(request):
- return
-
- view_module = view_func.__module__
- quarantined_modules = request.session.get('third_party_auth_quarantined_modules')
- if quarantined_modules is not None and not any(view_module.startswith(mod) for mod in quarantined_modules):
- request.session.flush()
diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py
index c768332270..917f398e7d 100644
--- a/common/djangoapps/third_party_auth/settings.py
+++ b/common/djangoapps/third_party_auth/settings.py
@@ -10,12 +10,9 @@ If true, it:
b) calls apply_settings(), passing in the Django settings
"""
-from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements
-
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next']
_MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware',
- 'third_party_auth.middleware.PipelineQuarantineMiddleware',
)
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
@@ -58,9 +55,6 @@ def apply_settings(django_settings):
'third_party_auth.pipeline.login_analytics',
]
- # Add enterprise pipeline elements if the enterprise app is installed
- insert_enterprise_pipeline_elements(django_settings.SOCIAL_AUTH_PIPELINE)
-
# Required so that we can use unmodified PSA OAuth2 backends:
django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy'
diff --git a/common/djangoapps/third_party_auth/tests/test_middleware.py b/common/djangoapps/third_party_auth/tests/test_middleware.py
deleted file mode 100644
index d2608fd415..0000000000
--- a/common/djangoapps/third_party_auth/tests/test_middleware.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-Test the session-flushing middleware
-"""
-import unittest
-
-from django.conf import settings
-from django.test import Client
-from social_django.models import Partial
-
-
-@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
-class TestSessionFlushMiddleware(unittest.TestCase):
- """
- Ensure that if the pipeline is exited when it's been quarantined,
- the entire session is flushed.
- """
- def setUp(self):
- self.client = Client()
- self.fancy_variable = 13025
- self.token = 'pipeline_running'
- self.tpa_quarantined_modules = ('fake_quarantined_module',)
-
- def tearDown(self):
- Partial.objects.all().delete()
-
- def test_session_flush(self):
- """
- Test that a quarantined session is flushed when navigating elsewhere
- """
- session = self.client.session
- session['fancy_variable'] = self.fancy_variable
- session['partial_pipeline_token'] = self.token
- session['third_party_auth_quarantined_modules'] = self.tpa_quarantined_modules
- session.save()
- Partial.objects.create(token=session.get('partial_pipeline_token'))
- self.client.get('/')
- self.assertEqual(self.client.session.get('fancy_variable', None), None)
-
- def test_session_no_running_pipeline(self):
- """
- Test that a quarantined session without a running pipeline is not flushed
- """
- session = self.client.session
- session['fancy_variable'] = self.fancy_variable
- session['third_party_auth_quarantined_modules'] = self.tpa_quarantined_modules
- session.save()
- self.client.get('/')
- self.assertEqual(self.client.session.get('fancy_variable', None), self.fancy_variable)
-
- def test_session_no_quarantine(self):
- """
- Test that a session with a running pipeline but no quarantine is not flushed
- """
- session = self.client.session
- session['fancy_variable'] = self.fancy_variable
- session['partial_pipeline_token'] = self.token
- session.save()
- Partial.objects.create(token=session.get('partial_pipeline_token'))
- self.client.get('/')
- self.assertEqual(self.client.session.get('fancy_variable', None), self.fancy_variable)
diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py
index 9bddb7ecb7..bfb4655404 100644
--- a/common/djangoapps/third_party_auth/tests/test_settings.py
+++ b/common/djangoapps/third_party_auth/tests/test_settings.py
@@ -2,7 +2,6 @@
import unittest
-from openedx.features.enterprise_support.api import enterprise_enabled
from third_party_auth import provider, settings
from third_party_auth.tests import testutil
@@ -56,8 +55,3 @@ class SettingsUnitTest(testutil.TestCase):
# bad in prod.
settings.apply_settings(self.settings)
self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS)
-
- @unittest.skipUnless(enterprise_enabled(), 'enterprise not enabled')
- def test_enterprise_elements_inserted(self):
- settings.apply_settings(self.settings)
- self.assertIn('enterprise.tpa_pipeline.handle_enterprise_logistration', self.settings.SOCIAL_AUTH_PIPELINE)
diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py
index bc22a6fb9e..e3502d494f 100644
--- a/common/lib/xmodule/xmodule/modulestore/__init__.py
+++ b/common/lib/xmodule/xmodule/modulestore/__init__.py
@@ -7,7 +7,6 @@ import logging
import re
import json
import datetime
-import traceback
from pytz import UTC
from collections import defaultdict
@@ -158,19 +157,7 @@ class ActiveBulkThread(threading.local):
"""
def __init__(self, bulk_ops_record_type, **kwargs):
super(ActiveBulkThread, self).__init__(**kwargs)
- self._records = defaultdict(bulk_ops_record_type)
- self.CMS_LEAK_DEBUG_GLOBAL = True # only log once per process
-
- @property
- def records(self):
- if self.CMS_LEAK_DEBUG_GLOBAL and len(self._records) > 2000: # arbitrary limit, we peak around ~2750 on edx.org
- log.info(
- "EDUCATOR-768: The memory leak issue may be in progress. How we got here:\n{}".format(
- "".join(traceback.format_stack())
- )
- )
- self.CMS_LEAK_DEBUG_GLOBAL = False
- return self._records
+ self.records = defaultdict(bulk_ops_record_type)
class BulkOperationsMixin(object):
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
index 3e1a393f52..16c238ec4b 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py
@@ -788,7 +788,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
if should_cache_items:
self.cache_items(runtime, block_keys, course_entry.course_key, depth, lazy)
- return [runtime.load_item(block_key, course_entry, **kwargs) for block_key in block_keys]
+ with self.bulk_operations(course_entry.course_key, emit_signals=False):
+ return [runtime.load_item(block_key, course_entry, **kwargs) for block_key in block_keys]
def _get_cache(self, course_version_guid):
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
index 59917387f5..b25a07415b 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py
@@ -418,7 +418,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
# wildcard query, 6! load pertinent items for inheritance calls, load parents, course root fetch (why)
# Split:
# active_versions (with regex), structure, and spurious active_versions refetch
- @ddt.data((ModuleStoreEnum.Type.mongo, 14, 0), (ModuleStoreEnum.Type.split, 3, 0))
+ @ddt.data((ModuleStoreEnum.Type.mongo, 14, 0), (ModuleStoreEnum.Type.split, 4, 0))
@ddt.unpack
def test_get_items(self, default_ms, max_find, max_send):
self.initdb(default_ms)
@@ -1043,7 +1043,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
# 1) wildcard split search,
# 2-4) active_versions, structure, definition (s/b lazy; so, unnecessary)
# 5) wildcard draft mongo which has none
- @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 5, 0))
+ @ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 6, 0))
@ddt.unpack
def test_get_courses(self, default_ms, max_find, max_send):
self.initdb(default_ms)
diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py
index e72a7f6d11..ec7871c4d6 100644
--- a/lms/djangoapps/courseware/access.py
+++ b/lms/djangoapps/courseware/access.py
@@ -21,7 +21,11 @@ from django.utils.timezone import UTC
from opaque_keys.edx.keys import CourseKey, UsageKey
from xblock.core import XBlock
-from courseware.access_response import MilestoneError, MobileAvailabilityError, VisibilityError
+from courseware.access_response import (
+ MilestoneAccessError,
+ MobileAvailabilityError,
+ VisibilityError,
+)
from courseware.access_utils import (
ACCESS_DENIED,
ACCESS_GRANTED,
@@ -309,7 +313,8 @@ def _has_access_course(user, action, courselike):
"""
response = (
_visible_to_nonstaff_users(courselike) and
- check_course_open_for_learner(user, courselike)
+ check_course_open_for_learner(user, courselike) and
+ _can_view_courseware_with_prerequisites(user, courselike)
)
return (
@@ -355,8 +360,6 @@ def _has_access_course(user, action, courselike):
checkers = {
'load': can_load,
- 'view_courseware_with_prerequisites':
- lambda: _can_view_courseware_with_prerequisites(user, courselike),
'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, courselike),
'enroll': can_enroll,
'see_exists': see_exists,
@@ -770,7 +773,7 @@ def _has_fulfilled_all_milestones(user, course_id):
course_id: ID of the course to check
user_id: ID of the user to check
"""
- return MilestoneError() if any_unfulfilled_milestones(course_id, user.id) else ACCESS_GRANTED
+ return MilestoneAccessError() if any_unfulfilled_milestones(course_id, user.id) else ACCESS_GRANTED
def _has_fulfilled_prerequisites(user, course_id):
@@ -782,7 +785,7 @@ def _has_fulfilled_prerequisites(user, course_id):
user: user to check
course_id: ID of the course to check
"""
- return MilestoneError() if get_pre_requisite_courses_not_completed(user, course_id) else ACCESS_GRANTED
+ return MilestoneAccessError() if get_pre_requisite_courses_not_completed(user, course_id) else ACCESS_GRANTED
def _has_catalog_visibility(course, visibility_type):
diff --git a/lms/djangoapps/courseware/access_response.py b/lms/djangoapps/courseware/access_response.py
index f9b4b6f703..6249502132 100644
--- a/lms/djangoapps/courseware/access_response.py
+++ b/lms/djangoapps/courseware/access_response.py
@@ -105,7 +105,7 @@ class StartDateError(AccessError):
super(StartDateError, self).__init__(error_code, developer_message, user_message)
-class MilestoneError(AccessError):
+class MilestoneAccessError(AccessError):
"""
Access denied because the user has unfulfilled milestones
"""
@@ -113,7 +113,7 @@ class MilestoneError(AccessError):
error_code = "unfulfilled_milestones"
developer_message = "User has unfulfilled milestones"
user_message = _("You have unfulfilled milestones")
- super(MilestoneError, self).__init__(error_code, developer_message, user_message)
+ super(MilestoneAccessError, self).__init__(error_code, developer_message, user_message)
class VisibilityError(AccessError):
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index 610bd8fc2f..eeed11b6d3 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -9,7 +9,7 @@ from datetime import datetime
import branding
import pytz
from courseware.access import has_access
-from courseware.access_response import StartDateError
+from courseware.access_response import StartDateError, MilestoneAccessError
from courseware.date_summary import (
CourseEndDate,
CourseStartDate,
@@ -32,6 +32,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
from path import Path as path
from static_replace import replace_static_urls
from student.models import CourseEnrollment
+from survey.utils import is_survey_required_and_unanswered
from util.date_utils import strftime_localized
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -72,7 +73,7 @@ def get_course_by_id(course_key, depth=0):
raise Http404("Course not found: {}.".format(unicode(course_key)))
-def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False):
+def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False, check_survey_complete=True):
"""
Given a course_key, look up the corresponding course descriptor,
check that the user has the access to perform the specified action
@@ -84,9 +85,14 @@ def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=
check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
or has staff access.
+ check_survey_complete: If true, additionally verifies that the user has either completed the course survey
+ or has staff access.
+ Note: We do not want to continually add these optional booleans. Ideally,
+ these special cases could not only be handled inside has_access, but could
+ be plugged in as additional callback checks for different actions.
"""
course = get_course_by_id(course_key, depth)
- check_course_access(course, user, action, check_if_enrolled)
+ check_course_access(course, user, action, check_if_enrolled, check_survey_complete)
return course
@@ -109,12 +115,13 @@ def get_course_overview_with_access(user, action, course_key, check_if_enrolled=
return course_overview
-def check_course_access(course, user, action, check_if_enrolled=False):
+def check_course_access(course, user, action, check_if_enrolled=False, check_survey_complete=True):
"""
Check that the user has the access to perform the specified action
on the course (CourseDescriptor|CourseOverview).
check_if_enrolled: If true, additionally verifies that the user is enrolled.
+ check_survey_complete: If true, additionally verifies that the user has completed the survey.
"""
# Allow staff full access to the course even if not enrolled
if has_access(user, 'staff', course.id):
@@ -130,7 +137,13 @@ def check_course_access(course, user, action, check_if_enrolled=False):
raise CourseAccessRedirect('{dashboard_url}?{params}'.format(
dashboard_url=reverse('dashboard'),
params=params.urlencode()
- ))
+ ), access_response)
+
+ # Redirect if the user must answer a survey before entering the course.
+ if isinstance(access_response, MilestoneAccessError):
+ raise CourseAccessRedirect('{dashboard_url}'.format(
+ dashboard_url=reverse('dashboard'),
+ ), access_response)
# Deliberately return a non-specific error message to avoid
# leaking info about access control settings
@@ -141,6 +154,11 @@ def check_course_access(course, user, action, check_if_enrolled=False):
if not CourseEnrollment.is_enrolled(user, course.id):
raise CourseAccessRedirect(reverse('about_course', args=[unicode(course.id)]))
+ # Redirect if the user must answer a survey before entering the course.
+ if check_survey_complete and action == 'load':
+ if is_survey_required_and_unanswered(user, course):
+ raise CourseAccessRedirect(reverse('course_survey', args=[unicode(course.id)]))
+
def can_self_enroll_in_course(course_key):
"""
diff --git a/lms/djangoapps/courseware/exceptions.py b/lms/djangoapps/courseware/exceptions.py
index f87e2e64e2..449b0105a3 100644
--- a/lms/djangoapps/courseware/exceptions.py
+++ b/lms/djangoapps/courseware/exceptions.py
@@ -15,5 +15,13 @@ class Redirect(Exception):
class CourseAccessRedirect(Redirect):
"""
Redirect raised when user does not have access to a course.
+
+ Arguments:
+ url (string): The redirect url.
+ access_error (AccessErro): The AccessError that caused the redirect.
+ The AccessError contains messages for developers and users explaining why
+ the user was denied access. These strings can then be exposed to the user.
"""
- pass
+ def __init__(self, url, access_error=None):
+ super(CourseAccessRedirect, self).__init__(url)
+ self.access_error = access_error
diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py
index 02e1671124..8afa9f2c2d 100644
--- a/lms/djangoapps/courseware/tests/test_access.py
+++ b/lms/djangoapps/courseware/tests/test_access.py
@@ -595,16 +595,16 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
# user should not be able to load course even if enrolled
CourseEnrollmentFactory(user=user, course_id=course.id)
- response = access._has_access_course(user, 'view_courseware_with_prerequisites', course)
+ response = access._has_access_course(user, 'load', course)
self.assertFalse(response)
- self.assertIsInstance(response, access_response.MilestoneError)
+ self.assertIsInstance(response, access_response.MilestoneAccessError)
# Staff can always access course
staff = StaffFactory.create(course_key=course.id)
- self.assertTrue(access._has_access_course(staff, 'view_courseware_with_prerequisites', course))
+ self.assertTrue(access._has_access_course(staff, 'load', course))
# User should be able access after completing required course
fulfill_course_milestone(pre_requisite_course.id, user)
- self.assertTrue(access._has_access_course(user, 'view_courseware_with_prerequisites', course))
+ self.assertTrue(access._has_access_course(user, 'load', course))
@ddt.data(
(True, True, True),
@@ -615,8 +615,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
"""
Test course access on mobile for staff and students.
"""
- descriptor = Mock(id=self.course.id, user_partitions=[])
- descriptor._class_tags = {}
+ descriptor = CourseFactory()
descriptor.visible_to_staff_only = False
descriptor.mobile_available = mobile_available
@@ -773,7 +772,7 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
PREREQUISITES_TEST_DATA = list(itertools.product(
['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous'],
- ['view_courseware_with_prerequisites'],
+ ['load'],
['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'],
))
diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py
index 00b4881808..31f74d61d9 100644
--- a/lms/djangoapps/courseware/tests/test_course_info.py
+++ b/lms/djangoapps/courseware/tests/test_course_info.py
@@ -388,7 +388,7 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
self.assertEqual(resp.status_code, 200)
def test_num_queries_instructor_paced(self):
- self.fetch_course_info_with_queries(self.instructor_paced_course, 25, 3)
+ self.fetch_course_info_with_queries(self.instructor_paced_course, 24, 3)
def test_num_queries_self_paced(self):
- self.fetch_course_info_with_queries(self.self_paced_course, 25, 3)
+ self.fetch_course_info_with_queries(self.self_paced_course, 24, 3)
diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py
index 4508797807..418a154886 100644
--- a/lms/djangoapps/courseware/tests/test_views.py
+++ b/lms/djangoapps/courseware/tests/test_views.py
@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20
@ddt.data(
- (ModuleStoreEnum.Type.mongo, 10, 143),
- (ModuleStoreEnum.Type.split, 4, 143),
+ (ModuleStoreEnum.Type.mongo, 10, 142),
+ (ModuleStoreEnum.Type.split, 4, 142),
)
@ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
@@ -1464,12 +1464,12 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced)
- with self.assertNumQueries(40, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
+ with self.assertNumQueries(39, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
self._get_progress_page()
@ddt.data(
- (False, 40, 26),
- (True, 33, 22)
+ (False, 39, 25),
+ (True, 32, 21)
)
@ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent):
diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py
index 3dafd532a4..c088ae3106 100644
--- a/lms/djangoapps/courseware/views/index.py
+++ b/lms/djangoapps/courseware/views/index.py
@@ -52,7 +52,6 @@ from ..model_data import FieldDataCache
from ..module_render import get_module_for_descriptor, toc_for_course
from .views import (
CourseTabView,
- check_access_to_course,
check_and_get_upgrade_link,
get_cosmetic_verified_display_price
)
@@ -136,7 +135,6 @@ class CoursewareIndex(View):
"""
Render the index page.
"""
- check_access_to_course(request, self.course)
self._redirect_if_needed_to_pay_for_course()
self._prefetch_and_bind_course(request)
diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py
index 426beaa225..b6e54558a3 100644
--- a/lms/djangoapps/courseware/views/views.py
+++ b/lms/djangoapps/courseware/views/views.py
@@ -9,7 +9,6 @@ from datetime import datetime, timedelta
import analytics
import shoppingcart
-import survey.utils
import survey.views
import waffle
from certificates import api as certs_api
@@ -82,7 +81,7 @@ from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
-from openedx.core.djangoapps.util.user_messages import register_warning_message
+from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, course_home_url_name
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
@@ -91,7 +90,6 @@ from openedx.features.enterprise_support.api import data_sharing_consent_require
from rest_framework import status
from shoppingcart.utils import is_shopping_cart_enabled
from student.models import CourseEnrollment, UserTestGroup
-from survey.utils import must_answer_survey
from util.cache import cache, cache_if_anonymous
from util.db import outer_atomic
from util.milestones_helpers import get_prerequisite_courses_display
@@ -278,10 +276,6 @@ def course_info(request, course_id):
if not user_is_enrolled and not can_self_enroll_in_course(course_key):
return redirect(reverse('dashboard'))
- # TODO: LEARNER-1865: Handle prereqs and course survey in new Course Home.
- # Redirect the user if they are not yet allowed to view this course
- check_access_to_course(request, course)
-
# LEARNER-170: Entrance exam is handled by new Course Outline. (DONE)
# If the user needs to take an entrance exam to access this course, then we'll need
# to send them to that specific course module before allowing them into other areas
@@ -424,9 +418,6 @@ class CourseTabView(EdxFragmentView):
with modulestore().bulk_operations(course_key):
course = get_course_with_access(request.user, 'load', course_key)
try:
- # Verify that the user has access to the course
- check_access_to_course(request, course)
-
# Show warnings if the user has limited access
self.register_user_access_warning_messages(request, course_key)
@@ -456,7 +447,7 @@ class CourseTabView(EdxFragmentView):
is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key)
is_staff = has_access(request.user, 'staff', course_key)
if request.user.is_anonymous():
- register_warning_message(
+ PageLevelMessages.register_warning_message(
request,
Text(_("To see course content, {sign_in_link} or {register_link}.")).format(
sign_in_link=HTML('
{sign_in_label} ').format(
@@ -470,7 +461,7 @@ class CourseTabView(EdxFragmentView):
)
)
elif not is_enrolled and not is_staff:
- register_warning_message(
+ PageLevelMessages.register_warning_message(
request,
Text(_('You must be enrolled in the course to see course content. {enroll_link}.')).format(
enroll_link=HTML('
{enroll_link_label} ').format(
@@ -739,8 +730,7 @@ def course_about(request, course_id):
show_courseware_link = bool(
(
- has_access(request.user, 'load', course) and
- has_access(request.user, 'view_courseware_with_prerequisites', course)
+ has_access(request.user, 'load', course)
) or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
)
@@ -848,7 +838,7 @@ def program_marketing(request, program_uuid):
"""
Display the program marketing page.
"""
- program_data = get_programs(uuid=program_uuid)
+ program_data = get_programs(request.site, uuid=program_uuid)
if not program_data:
raise Http404
@@ -921,9 +911,6 @@ def _progress(request, course_key, student_id):
# NOTE: To make sure impersonation by instructor works, use
# student instead of request.user in the rest of the function.
- # Redirect the user if they are not yet allowed to view this course
- check_access_to_course(request, course)
-
# The pre-fetching of groups is done to make auth checks not require an
# additional DB lookup (this kills the Progress page in particular).
student = User.objects.prefetch_related("groups").get(id=student.id)
@@ -1311,7 +1298,7 @@ def course_survey(request, course_id):
"""
course_key = CourseKey.from_string(course_id)
- course = get_course_with_access(request.user, 'load', course_key)
+ course = get_course_with_access(request.user, 'load', course_key, check_survey_complete=False)
redirect_url = reverse(course_home_url_name(course.id), args=[course_id])
@@ -1721,22 +1708,3 @@ def get_financial_aid_courses(user):
)
return financial_aid_courses
-
-
-def check_access_to_course(request, course):
- """
- Raises Redirect exceptions if the user does not have course access.
- """
- # TODO: LEARNER-1865: Handle prereqs in new Course Home.
- # Redirect to the dashboard if not all prerequisites have been met
- if not has_access(request.user, 'view_courseware_with_prerequisites', course):
- log.info(
- u'User %d tried to view course %s '
- u'without fulfilling prerequisites',
- request.user.id, unicode(course.id))
- raise CourseAccessRedirect(reverse('dashboard'))
-
- # TODO: LEARNER-1865: Handle course surveys in new Course Home.
- # Redirect if the user must answer a survey before entering the course.
- if must_answer_survey(course, request.user):
- raise CourseAccessRedirect(reverse('course_survey', args=[unicode(course.id)]))
diff --git a/lms/djangoapps/django_comment_client/base/tests.py b/lms/djangoapps/django_comment_client/base/tests.py
index 3c13e5c650..23ccb09113 100644
--- a/lms/djangoapps/django_comment_client/base/tests.py
+++ b/lms/djangoapps/django_comment_client/base/tests.py
@@ -404,8 +404,8 @@ class ViewsQueryCountTestCase(
return inner
@ddt.data(
- (ModuleStoreEnum.Type.mongo, 3, 4, 32),
- (ModuleStoreEnum.Type.split, 3, 12, 32),
+ (ModuleStoreEnum.Type.mongo, 3, 4, 31),
+ (ModuleStoreEnum.Type.split, 3, 13, 31),
)
@ddt.unpack
@count_queries
@@ -413,8 +413,8 @@ class ViewsQueryCountTestCase(
self.create_thread_helper(mock_request)
@ddt.data(
- (ModuleStoreEnum.Type.mongo, 3, 3, 28),
- (ModuleStoreEnum.Type.split, 3, 9, 28),
+ (ModuleStoreEnum.Type.mongo, 3, 3, 27),
+ (ModuleStoreEnum.Type.split, 3, 10, 27),
)
@ddt.unpack
@count_queries
diff --git a/lms/djangoapps/grades/new/course_grade.py b/lms/djangoapps/grades/new/course_grade.py
index 745c6434b4..b438001d07 100644
--- a/lms/djangoapps/grades/new/course_grade.py
+++ b/lms/djangoapps/grades/new/course_grade.py
@@ -21,7 +21,7 @@ class CourseGradeBase(object):
"""
Base class for Course Grades.
"""
- def __init__(self, user, course_data, percent=0, letter_grade=None, passed=False):
+ def __init__(self, user, course_data, percent=0, letter_grade=None, passed=False, force_update_subsections=False):
self.user = user
self.course_data = course_data
@@ -30,6 +30,7 @@ class CourseGradeBase(object):
# Convert empty strings to None when reading from the table
self.letter_grade = letter_grade or None
+ self.force_update_subsections = force_update_subsections
def __unicode__(self):
return u'Course Grade: percent: {}, letter_grade: {}, passed: {}'.format(
@@ -203,7 +204,9 @@ class CourseGrade(CourseGradeBase):
def update(self):
"""
- Updates the grade for the course.
+ Updates the grade for the course. Also updates subsection grades
+ if self.force_update_subsections is true, via the lazy call
+ to self.grader_result.
"""
grade_cutoffs = self.course_data.course.grade_cutoffs
self.percent = self._compute_percent(self.grader_result)
@@ -224,7 +227,10 @@ class CourseGrade(CourseGradeBase):
def _get_subsection_grade(self, subsection):
# Pass read_only here so the subsection grades can be persisted in bulk at the end.
- return self._subsection_grade_factory.create(subsection, read_only=True)
+ if self.force_update_subsections:
+ return self._subsection_grade_factory.update(subsection)
+ else:
+ return self._subsection_grade_factory.create(subsection, read_only=True)
@staticmethod
def _compute_percent(grader_result):
diff --git a/lms/djangoapps/grades/new/course_grade_factory.py b/lms/djangoapps/grades/new/course_grade_factory.py
index 9085436068..4ffe091b79 100644
--- a/lms/djangoapps/grades/new/course_grade_factory.py
+++ b/lms/djangoapps/grades/new/course_grade_factory.py
@@ -66,7 +66,15 @@ class CourseGradeFactory(object):
else:
return None
- def update(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None):
+ def update(
+ self,
+ user,
+ course=None,
+ collected_block_structure=None,
+ course_structure=None,
+ course_key=None,
+ force_update_subsections=False,
+ ):
"""
Computes, updates, and returns the CourseGrade for the given
user in the course.
@@ -75,7 +83,7 @@ class CourseGradeFactory(object):
or course_key should be provided.
"""
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
- return self._update(user, course_data, read_only=False)
+ return self._update(user, course_data, read_only=False, force_update_subsections=force_update_subsections)
@contextmanager
def _course_transaction(self, course_key):
@@ -118,10 +126,17 @@ class CourseGradeFactory(object):
def _iter_grade_result(self, user, course_data, force_update):
try:
+ kwargs = {
+ 'user': user,
+ 'course': course_data.course,
+ 'collected_block_structure': course_data.collected_structure,
+ 'course_key': course_data.course_key
+ }
+ if force_update:
+ kwargs['force_update_subsections'] = True
+
method = CourseGradeFactory().update if force_update else CourseGradeFactory().create
- course_grade = method(
- user, course_data.course, course_data.collected_structure, course_key=course_data.course_key,
- )
+ course_grade = method(**kwargs)
return self.GradeResult(user, course_grade, None)
except Exception as exc: # pylint: disable=broad-except
# Keep marching on even if this student couldn't be graded for
@@ -165,14 +180,14 @@ class CourseGradeFactory(object):
return course_grade, persistent_grade.grading_policy_hash
@staticmethod
- def _update(user, course_data, read_only):
+ def _update(user, course_data, read_only, force_update_subsections=False):
"""
Computes, saves, and returns a CourseGrade object for the
given user and course.
Sends a COURSE_GRADE_CHANGED signal to listeners and a
COURSE_GRADE_NOW_PASSED if learner has passed course.
"""
- course_grade = CourseGrade(user, course_data)
+ course_grade = CourseGrade(user, course_data, force_update_subsections=force_update_subsections)
course_grade.update()
should_persist = (
diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py
index 3bb8d9e3f9..3e4df7605e 100644
--- a/lms/djangoapps/grades/tasks.py
+++ b/lms/djangoapps/grades/tasks.py
@@ -106,7 +106,7 @@ def compute_grades_for_course_v2(self, **kwargs):
@task(base=_BaseTask)
def compute_grades_for_course(course_key, offset, batch_size, **kwargs): # pylint: disable=unused-argument
"""
- Compute grades for a set of students in the specified course.
+ Compute and save grades for a set of students in the specified course.
The set of students will be determined by the order of enrollment date, and
limited to at most
students, starting from the specified
diff --git a/lms/djangoapps/grades/tests/test_new.py b/lms/djangoapps/grades/tests/test_new.py
index d5a942df0c..fb7feccbf0 100644
--- a/lms/djangoapps/grades/tests/test_new.py
+++ b/lms/djangoapps/grades/tests/test_new.py
@@ -246,6 +246,20 @@ class TestCourseGradeFactory(GradeTestBase):
else:
self.assertIsNone(course_grade)
+ @ddt.data(True, False)
+ def test_iter_force_update(self, force_update):
+ base_string = 'lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.{}'
+ desired_method_name = base_string.format('update' if force_update else 'create')
+ undesired_method_name = base_string.format('create' if force_update else 'update')
+ with patch(desired_method_name) as desired_call:
+ with patch(undesired_method_name) as undesired_call:
+ set(CourseGradeFactory().iter(
+ users=[self.request.user], course=self.course, force_update=force_update
+ ))
+
+ self.assertTrue(desired_call.called)
+ self.assertFalse(undesired_call.called)
+
@ddt.ddt
class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py
index e54ab41236..e166ee1ea0 100644
--- a/lms/djangoapps/learner_dashboard/views.py
+++ b/lms/djangoapps/learner_dashboard/views.py
@@ -25,7 +25,7 @@ def program_listing(request):
if not programs_config.enabled:
raise Http404
- meter = ProgramProgressMeter(request.user)
+ meter = ProgramProgressMeter(request.site, request.user)
context = {
'disable_courseware_js': True,
@@ -48,7 +48,7 @@ def program_details(request, program_uuid):
if not programs_config.enabled:
raise Http404
- meter = ProgramProgressMeter(request.user, uuid=program_uuid)
+ meter = ProgramProgressMeter(request.site, request.user, uuid=program_uuid)
program_data = meter.programs[0]
if not program_data:
diff --git a/lms/djangoapps/mobile_api/decorators.py b/lms/djangoapps/mobile_api/decorators.py
index e8eb924562..5adda15c79 100644
--- a/lms/djangoapps/mobile_api/decorators.py
+++ b/lms/djangoapps/mobile_api/decorators.py
@@ -42,6 +42,10 @@ def mobile_course_access(depth=0):
except CoursewareAccessException as error:
return Response(data=error.to_json(), status=status.HTTP_404_NOT_FOUND)
except CourseAccessRedirect as error:
+ # If the redirect contains information about the triggering AccessError,
+ # return the information contained in the AccessError.
+ if error.access_error is not None:
+ return Response(data=error.access_error.to_json(), status=status.HTTP_404_NOT_FOUND)
# Raise a 404 if the user does not have course access
raise Http404
return func(self, request, course=course, *args, **kwargs)
diff --git a/lms/djangoapps/mobile_api/tests/test_milestones.py b/lms/djangoapps/mobile_api/tests/test_milestones.py
index 3c7f22b4e2..46495cff90 100644
--- a/lms/djangoapps/mobile_api/tests/test_milestones.py
+++ b/lms/djangoapps/mobile_api/tests/test_milestones.py
@@ -4,7 +4,7 @@ Milestone related tests for the mobile_api
from django.conf import settings
from mock import patch
-from courseware.access_response import MilestoneError
+from courseware.access_response import MilestoneAccessError
from courseware.tests.test_entrance_exam import add_entrance_exam_milestone, answer_entrance_exam_problem
from openedx.core.djangolib.testing.utils import get_mock_request
from util.milestones_helpers import add_prerequisite_course, fulfill_course_milestone
@@ -136,4 +136,4 @@ class MobileAPIMilestonesMixin(object):
self.api_response()
else:
response = self.api_response(expected_response_code=404)
- self.assertEqual(response.data, MilestoneError().to_json())
+ self.assertEqual(response.data, MilestoneAccessError().to_json())
diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py
index a385dcd447..d9167318f0 100644
--- a/lms/djangoapps/mobile_api/users/tests.py
+++ b/lms/djangoapps/mobile_api/users/tests.py
@@ -18,7 +18,7 @@ from certificates.api import generate_user_certificates
from certificates.models import CertificateStatuses
from certificates.tests.factories import GeneratedCertificateFactory
from course_modes.models import CourseMode
-from courseware.access_response import MilestoneError, StartDateError, VisibilityError
+from courseware.access_response import MilestoneAccessError, StartDateError, VisibilityError
from lms.djangoapps.grades.tests.utils import mock_passing_grade
from mobile_api.testutils import (
MobileAPITestCase,
@@ -155,7 +155,7 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest
]
expected_error_codes = [
- MilestoneError().error_code, # 'unfulfilled_milestones'
+ MilestoneAccessError().error_code, # 'unfulfilled_milestones'
StartDateError(self.NEXT_WEEK).error_code, # 'course_not_started'
VisibilityError().error_code, # 'not_visible_to_user'
None,
diff --git a/lms/djangoapps/survey/tests/test_utils.py b/lms/djangoapps/survey/tests/test_utils.py
index 01da86079d..f4ec44a6e8 100644
--- a/lms/djangoapps/survey/tests/test_utils.py
+++ b/lms/djangoapps/survey/tests/test_utils.py
@@ -8,7 +8,7 @@ from django.contrib.auth.models import User
from django.test.client import Client
from survey.models import SurveyForm
-from survey.utils import is_survey_required_for_course, must_answer_survey
+from survey.utils import is_survey_required_for_course, is_survey_required_and_unanswered
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -89,28 +89,28 @@ class SurveyModelsTests(ModuleStoreTestCase):
"""
Assert that a new course which has a required survey but user has not answered it yet
"""
- self.assertTrue(must_answer_survey(self.course, self.student))
+ self.assertTrue(is_survey_required_and_unanswered(self.student, self.course))
temp_course = CourseFactory.create(
course_survey_required=False
)
- self.assertFalse(must_answer_survey(temp_course, self.student))
+ self.assertFalse(is_survey_required_and_unanswered(self.student, temp_course))
temp_course = CourseFactory.create(
course_survey_required=True,
course_survey_name="NonExisting"
)
- self.assertFalse(must_answer_survey(temp_course, self.student))
+ self.assertFalse(is_survey_required_and_unanswered(self.student, temp_course))
def test_user_has_answered_required_survey(self):
"""
Assert that a new course which has a required survey and user has answers for it
"""
self.survey.save_user_answers(self.student, self.student_answers, None)
- self.assertFalse(must_answer_survey(self.course, self.student))
+ self.assertFalse(is_survey_required_and_unanswered(self.student, self.course))
def test_staff_must_answer_survey(self):
"""
Assert that someone with staff level permissions does not have to answer the survey
"""
- self.assertFalse(must_answer_survey(self.course, self.staff))
+ self.assertFalse(is_survey_required_and_unanswered(self.staff, self.course))
diff --git a/lms/djangoapps/survey/utils.py b/lms/djangoapps/survey/utils.py
index 1af29f2d40..109c72ca21 100644
--- a/lms/djangoapps/survey/utils.py
+++ b/lms/djangoapps/survey/utils.py
@@ -1,9 +1,8 @@
"""
-Helper methods for Surveys
+Utilities for determining whether or not a survey needs to be completed.
"""
-
from courseware.access import has_access
-from survey.models import SurveyAnswer, SurveyForm
+from survey.models import SurveyForm, SurveyAnswer
def is_survey_required_for_course(course_descriptor):
@@ -11,17 +10,19 @@ def is_survey_required_for_course(course_descriptor):
Returns whether a Survey is required for this course
"""
- # check to see that the Survey name has been defined in the CourseDescriptor
- # and that the specified Survey exists
+ # Check to see that the survey is required in the CourseDescriptor.
+ if not getattr(course_descriptor, 'course_survey_required', False):
+ return False
- return course_descriptor.course_survey_required and \
- SurveyForm.get(course_descriptor.course_survey_name, throw_if_not_found=False)
+ # Check that the specified Survey for the course exists.
+ return SurveyForm.get(course_descriptor.course_survey_name, throw_if_not_found=False)
-def must_answer_survey(course_descriptor, user):
+def is_survey_required_and_unanswered(user, course_descriptor):
"""
- Returns whether a user needs to answer a required survey
+ Returns whether a user is required to answer the survey and has yet to do so.
"""
+
if not is_survey_required_for_course(course_descriptor):
return False
@@ -29,13 +30,13 @@ def must_answer_survey(course_descriptor, user):
if user.is_anonymous():
return False
- # this will throw exception if not found, but a non existing survey name will
- # be trapped in the above is_survey_required_for_course() method
- survey = SurveyForm.get(course_descriptor.course_survey_name)
-
+ # course staff do not need to answer survey
has_staff_access = has_access(user, 'staff', course_descriptor)
+ if has_staff_access:
+ return False
# survey is required and it exists, let's see if user has answered the survey
- # course staff do not need to answer survey
+ survey = SurveyForm.get(course_descriptor.course_survey_name)
answered_survey = SurveyAnswer.do_survey_answers_exist(survey, user)
- return not answered_survey and not has_staff_access
+ if not answered_survey:
+ return True
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 79804b06f5..63d28a9767 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -580,14 +580,6 @@ DATADOG.update(ENV_TOKENS.get("DATADOG", {}))
if 'DATADOG_API' in AUTH_TOKENS:
DATADOG['api_key'] = AUTH_TOKENS['DATADOG_API']
-# Analytics dashboard server
-ANALYTICS_SERVER_URL = ENV_TOKENS.get("ANALYTICS_SERVER_URL")
-ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", "")
-
-# Analytics data source
-ANALYTICS_DATA_URL = ENV_TOKENS.get("ANALYTICS_DATA_URL", ANALYTICS_DATA_URL)
-ANALYTICS_DATA_TOKEN = AUTH_TOKENS.get("ANALYTICS_DATA_TOKEN", ANALYTICS_DATA_TOKEN)
-
# Analytics Dashboard
ANALYTICS_DASHBOARD_URL = ENV_TOKENS.get("ANALYTICS_DASHBOARD_URL", ANALYTICS_DASHBOARD_URL)
ANALYTICS_DASHBOARD_NAME = ENV_TOKENS.get("ANALYTICS_DASHBOARD_NAME", PLATFORM_NAME + " Insights")
diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json
index a25ec6fb09..e8a9e61894 100644
--- a/lms/envs/bok_choy.auth.json
+++ b/lms/envs/bok_choy.auth.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CC_PROCESSOR_NAME": "CyberSource2",
diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json
index f114e1057f..edf485341e 100644
--- a/lms/envs/bok_choy.env.json
+++ b/lms/envs/bok_choy.env.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_SERVER_URL": "",
"ANALYTICS_DASHBOARD_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",
diff --git a/lms/envs/bok_choy_docker.auth.json b/lms/envs/bok_choy_docker.auth.json
index f37b564a27..9313c40250 100644
--- a/lms/envs/bok_choy_docker.auth.json
+++ b/lms/envs/bok_choy_docker.auth.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CC_PROCESSOR_NAME": "CyberSource2",
diff --git a/lms/envs/bok_choy_docker.env.json b/lms/envs/bok_choy_docker.env.json
index 0d33707eff..7858095afa 100644
--- a/lms/envs/bok_choy_docker.env.json
+++ b/lms/envs/bok_choy_docker.env.json
@@ -1,5 +1,4 @@
{
- "ANALYTICS_SERVER_URL": "",
"ANALYTICS_DASHBOARD_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 65e48d144f..52a991bab3 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -2846,9 +2846,7 @@ ADVANCED_SECURITY_CONFIG = {}
SHIBBOLETH_DOMAIN_PREFIX = 'shib:'
OPENID_DOMAIN_PREFIX = 'openid:'
-### Analytics Data API + Dashboard (Insights) settings
-ANALYTICS_DATA_URL = ""
-ANALYTICS_DATA_TOKEN = ""
+### Analytics Dashboard (Insights) settings
ANALYTICS_DASHBOARD_URL = ""
ANALYTICS_DASHBOARD_NAME = PLATFORM_NAME + " Insights"
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index f3dc4e8532..fb924b8ce0 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -234,11 +234,6 @@ FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True
PIPELINE_SASS_ARGUMENTS = '--debug-info'
-########################## ANALYTICS TESTING ########################
-
-ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
-ANALYTICS_API_KEY = ""
-
##### Segment ######
# If there's an environment variable set, grab it
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index 909259f278..d1f84397d0 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -43,9 +43,6 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
########################## ANALYTICS TESTING ########################
-ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
-ANALYTICS_API_KEY = ""
-
# Set this to the dashboard URL in order to display the link from the
# dashboard to the Analytics Dashboard.
ANALYTICS_DASHBOARD_URL = None
diff --git a/lms/static/sass/_experiments.scss b/lms/static/sass/_experiments.scss
index fee87f3911..d6aa483b71 100644
--- a/lms/static/sass/_experiments.scss
+++ b/lms/static/sass/_experiments.scss
@@ -4,23 +4,27 @@
// Please list the ticket number of the experiment
// --------------------
-// LEARNER-1312 Track Selection V2
-/* This css was added as part of the LEARNER-1312 experiment */
+// LEARNER-1726 Track Selection V3
+/* This css was added as part of the LEARNER-1726 experiment */
.v2.register-choice {
margin: 0 2% 20px 0 !important
}
+
.v2.register-choice-certificate .list-actions {
text-align: left !important;
}
-.v2.register-choice-donate .list-actions {
+
+.v2.register-choice-continue .list-actions {
margin-bottom: 0 !important;
}
-.v2.register-choice-donate .action-select {
+
+.v2.register-choice-continue .action-select {
display: inline-block !important;
list-style-type: none !important;
width: 100% !important;
}
-.v2.register-choice-donate .donation-link {
+
+.v2.register-choice-continue .continue-link {
display: inline-block !important;
padding: 10px 15px !important;
border-radius: 3px !important;
@@ -30,219 +34,360 @@
text-align: center !important;
color: #D7548E !important;
float: left !important;
+ font-size: 15px;
+ font-weight: 500 !important;
}
-.v2.register-choice-v2-audit {
- height: 250px !important;
+
+.v2.register-choice-v2-donate {
+ height: 300px;
background: none !important;
border-top-color: grey !important;
border-top-width: 1px !important;
}
-.v2.register-choice-v2-audit .list-actions {
+
+@media screen and (min-width: 375px) {
+ .v2.register-choice-v2-donate {
+ height: 250px;
+ }
+}
+
+.v2.register-choice-v2-donate .list-actions {
margin-bottom: 0 !important;
}
-.v2.register-choice-v2-audit .list-actions input {
+
+.v2.register-choice-v2-donate .list-actions a {
background: transparent !important;
color: #0075b4 !important;
box-shadow: none !important;
text-decoration: underline !important;
border: none !important;
+ white-space: normal;
}
-.v2.register-choice-v2-audit .wrapper-copy-inline {
+
+.v2.register-choice-v2-donate .wrapper-copy-inline {
height: 70px !important;
width: 100% !important;
display: flex !important;
}
-.v2.register-choice-v2-audit .wrapper-copy {
+
+.v2.register-choice-v2-donate .wrapper-copy {
width: 70% !important;
height: auto !important;
}
.v2.page-header {
- padding-bottom: 0;
+ padding: 0;
}
+
.v2 img {
margin-top: 20px;
margin-left: 5px;
}
-.v2 .donation-link {
+
+.v2 .continue-link {
font-weight: bold !important;
}
-@media (min-width: 320px) {
- .v2.register-choice-certificate,
- .v2.register-choice-donate,
- .v2.register-choice-view {
- width: 100%;
- }
- .v2 .wrapper-copy-inline {
- max-height: 115px;
- }
- .v2.register-choice-v2-audit .wrapper-copy-inline {
- display: block !important;
- }
- .v2.register-choice-v2-audit .copy-inline {
- width: 100% !important;
- }
- .v2.register-choice-v2-audit .list-actions {
- width: 100% !important;
- margin-top: 20px !important;
- text-align: center !important;
- }
- .v2 .wrapper-copy-inline .wrapper-copy {
- width: 100% !important;
- }
- .v2 .donation-link, .v2 input {
- width: 100% !important;
- font-size: 15px !important;
- }
- .v2 img {
- display: none;
+
+.v2.register-choice-certificate,
+.v2.register-choice-continue,
+.v2.register-choice-view {
+ width: 100%;
+}
+
+.v2.register-choice-continue {
+ border-color: #D7548E !important;
+}
+
+.v2 .wrapper-copy-inline {
+ max-height: 115px;
+}
+
+.v2.register-choice-v2-donate .wrapper-copy-inline {
+ display: block !important;
+}
+
+.v2.register-choice-v2-donate .copy-inline {
+ width: 100% !important;
+}
+
+.v2.register-choice-v2-donate .list-actions {
+ width: 100% !important;
+ margin-top: 20px !important;
+ text-align: center !important;
+}
+
+.v2 .wrapper-copy-inline .wrapper-copy {
+ width: 100% !important;
+}
+
+.v2 input, .v2 a {
+ font-size: 15px !important;
+}
+
+.v2 button {
+ background-color: rgb(0, 103, 0);
+ border-color: rgb(0, 103, 0);
+ border-radius: 2px;
+ box-shadow: rgb(0, 77, 0) 0px 2px 1px 0px;
+ cursor: pointer;
+ font-family: "Open Sans";
+ height: auto;
+ margin-right: 4px;
+ margin-top: 0px;
+ padding: 10px 15px;
+ width: initial;
+ background-image: none !important;
+ font-size: 14px !important;
+ font-weight: 500 !important;
+
+ &:hover, &:focus {
+ background-color: #009b00 !important;
+ border-color: #009b00;
+ box-shadow: #004d00 0px 2px 1px 0px;
}
}
+
+.savings-message {
+ margin-top: 10px;
+ font-size: 11px;
+}
+@media screen and (min-width: 375px) {
+ .savings-message {
+ font-size: 13px;
+ margin-left: 16px;
+ }
+}
+
+.v2 .continue-link, .v2 input, .v2 button, .v2 a {
+ width: 100%;
+}
+
+.v2 img {
+ display: none;
+}
+
+.v2 .deco-divider {
+ display: none;
+}
+
+.v2 .visual-reference {
+ width: 38%;
+}
+
+@media (min-width: 420px) {
+ .v2 button {
+ height: 45px;
+ font-size: 16px !important;
+ }
+}
+
@media (min-width: 768px) {
.v2.register-choice-certificate,
- .v2.register-choice-donate {
- width: 48% !important;
+ .v2.register-choice-continue,
+ .v2.deco-divider {
+ width: 46.5% !important;
display: inline-block;
- min-height: 250px;
+ min-height: 270px;
}
- .v2.register-choice-v2-audit .wrapper-copy-inline {
+
+ .v2.register-choice-v2-donate .wrapper-copy-inline {
display: flex !important;
}
- .v2.register-choice-v2-audit .copy-inline {
+
+ .v2.register-choice-v2-donate .copy-inline {
width: 40% !important;
}
- .v2.register-choice-v2-audit .list-actions {
+
+ .v2.register-choice-v2-donate .list-actions {
margin-top: 0 !important;
text-align: right !important;
}
+
.v2 .wrapper-copy-inline .wrapper-copy {
- width: 58% !important;
+ width: 100% !important;
}
- .v2 .donation-link, .v2 input {
+
+ .v2 input, .v2 a {
font-size: 15px !important;
- width: 55% !important;
}
+
+ .v2 .continue-link, .v2.register-choice-certificate button, .v2.register-choice-certificate input {
+ margin-top: 20px;
+ width: initial;
+ }
+
+ .v2.register-choice-v2-donate a {
+ width: 100% !important;
+ }
+
.v2.register-choice-view {
height: 250px;
}
+
.v2 img {
display: initial;
}
+
.v2.register-choice {
margin: 0 2% 20px 0;
}
+ .v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
+ width: 60%;
+ }
+
+ .v2.register-choice-view .wrapper-copy-inline .wrapper-copy {
+ width: 100%;
+ }
+
+ .v2.register-choice {
+ padding: 15px !important;
+ }
+
+ .v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
+ width: 60%;
+ }
+
+ .v2.register-choice {
+ padding: 20px !important;
+ }
+
+ .v2.register-choice.register-choice-view {
+ margin-right: 0;
+ }
+
+ .v2.register-choice .list-actions:last-child {
+ float: left;
+ width: 100%;
+ margin-top: 0px;
+ }
+
+ .v2.register-choice .action-select {
+ width: 100% !important;
+ }
+
+ .v2 .continue-link:hover,
+ .v2 .continue-link:focus {
+ background-color: #D7548E !important;
+ color: white !important;
+ text-decoration: none;
+ }
+
+ .v2 .continue-link:hover {
+ cursor: pointer;
+ }
+
+ .v2 .copy li {
+ margin-bottom: 5px;
+ }
+
+ .v2.register-choice .copy-inline {
+ width: 100%;
+ }
+
+ .v2 .register-choice-view {
+ border-color: #2991c3 !important;
+ }
+
+ .v2 .visual-reference {
+ vertical-align: top;
+ }
+
+ .v2 .wrapper-copy-inline .wrapper-copy ul {
+ margin-top: 0px;
+ padding-left: 30px;
+ }
+
+ .v2 .img-certificate {
+ border: 2px solid #009b00 !important;
+ float: right;
+ height: 120px;
+ width: auto;
+ margin-top: 0 !important;
+ display: none;
+ }
+
+ .v2 .img-donate {
+ margin-top: 0;
+ float: right;
+ border: 2px solid #D7548E !important;
+ display: none;
+ }
+
+ .v2 .img-view {
+ border: 2px solid #2991c3 !important;
+ }
+
+ .v2.register-choice .title {
+ width: 100%;
+ margin-bottom: 20px;
+ }
+
+ .v2.register-choice.register-choice-view .action-select {
+ border: 1px solid transparent !important;
+ border-radius: 3px;
+ }
+
+ .v2.register-choice.register-choice-view .action-select button {
+ border: 1px solid transparent !important;
+ }
+
+ .v2.register-choice.register-choice-view .action-select:hover {
+ border: 1px solid #0075b4 !important;
+ }
+
.v2.deco-divider {
width: 3% !important;
box-sizing: border-box;
float: left;
display: inline-block;
- height: 400px;
+ height: 250px;
margin: 0px 0 40px 0 !important;
- border-left: 4px solid #f5f5f5 !important;
- border-top: none !important;
+ border-left: 4px solid #f5f5f5 !important; border-top:none !important;
+
+ .copy {
+ position: absolute;
+ top: 110px !important;
+ left: calc(50% - 40px) !important;
+ margin-left: 20px;
+ background: white;
+ text-align: center;
+ color: #474747;
+ width: 10px;
+ padding: 0 !important;
+ }
}
}
-@media (min-width: 320px) {
- .v2 .visual-reference {
- width: 38%;
- }
-}
-@media (min-width: 768px) {
-
- @media (min-width: 320px) {
- .v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
- width: 60%;
- }
- }
- @media (min-width: 768px) {
- .v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
- width: 60%;
- }
- }
- @media (min-width: 320px) {
- .v2.register-choice-view .wrapper-copy-inline .wrapper-copy {
- width: 100%;
- }
- }
- @media (min-width: 320px) {
- .v2.register-choice {
- padding: 15px !important;
- }
- }
- @media (min-width: 768px) {
- .v2.register-choice {
- padding: 20px !important;
- }
- .v2.register-choice.register-choice-view {
- margin-right: 0;
- }
- }
- @media screen and (min-width: 769px) {
- .v2.register-choice .list-actions:last-child {
- float: left;
- width: 100%;
- margin-top: 0px;
- }
- }
- @media screen and (min-width: 769px) {
- .v2.register-choice .action-select {
- width: 100% !important;
- }
- }
- .v2 .donation-link:hover,
- .v2 .donation-link:focus {
- background-color: #D7548E !important;
- color: white !important;
- text-decoration: none;
- }
- .v2 .donation-link:hover {
- cursor: pointer;
- }
- .v2 .copy li {
- margin-bottom: 5px;
- }
- .v2.register-choice .copy-inline {
- width: 100%;
- }
- .v2.register-choice-donate {
- border-color: #D7548E !important;
- }
- .v2 .register-choice-view {
- border-color: #2991c3 !important;
- }
- .v2 .visual-reference {
- vertical-align: top;
- }
- .v2 .wrapper-copy-inline .wrapper-copy ul {
- margin-top: 0px;
- padding-left: 30px;
- }
- .v2 .img-certificate {
- border: 2px solid #009b00 !important;
- }
- .v2 .img-donate {
- border: 2px solid #D7548E !important;
- }
- .v2 .img-view {
- border: 2px solid #2991c3 !important;
- }
- .v2.register-choice .title {
- width: 100%;
- margin-bottom: 20px;
- }
- .v2.register-choice.register-choice-view .action-select {
- border: 1px solid transparent !important;
- border-radius: 3px;
- }
- .v2.register-choice.register-choice-view .action-select input {
- border: 1px solid transparent !important;
- }
- .v2.register-choice.register-choice-view .action-select:hover {
- border: 1px solid #0075b4 !important;
- }
+@media (min-width: 835px) {
+ .v2.register-choice-certificate,
+ .v2.register-choice-continue,
.v2.deco-divider {
- display: none !important;
+ min-height: 250px;
}
}
+
+@media (min-width: 1024px) {
+ .v2 .continue-link {
+ width: 55%;
+ }
+ .v2.deco-divider .copy {
+ margin-left: 15px;
+ }
+}
+
+@media (min-width: 1096px) {
+ .v2.register-choice-certificate,
+ .v2.register-choice-continue,
+ .v2.deco-divider {
+ min-height: 260px;
+ }
+ .v2 .img-certificate, .v2 .img-donate {
+ margin-top: 10px;
+ display: initial;
+ }
+ .v2 .continue-link, .v2.register-choice-certificate button,
+ .v2.register-choice-certificate input {
+ margin-top: -22px !important;
+ }
+}
\ No newline at end of file
diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss
index 84464da698..e552f6a685 100644
--- a/lms/static/sass/features/_course-experience.scss
+++ b/lms/static/sass/features/_course-experience.scss
@@ -1,3 +1,69 @@
+// ------------------------------
+// Styling for files located in the openedx/features repository.
+
+// Course call to action message
+.course-message {
+ .message-author {
+ display: inline-block;
+ width: 70px;
+ border-radius: $baseline*7/4;
+ border: 1px solid $lms-border-color;
+
+ @media (max-width: $grid-breakpoints-md) {
+ display: none;
+ }
+ }
+
+ .message-content {
+ position: relative;
+ border: 1px solid $lms-border-color;
+ margin: 0 $baseline $baseline/2;
+ padding: $baseline/2 $baseline;
+ border-radius: $baseline/4;
+
+ @media (max-width: $grid-breakpoints-md) {
+ width: 100%;
+ margin: $baseline 0;
+ }
+
+ &:after, &:before {
+ @include left(0);
+ bottom: 35%;
+ border: solid transparent;
+ height: 0;
+ width: 0;
+ content: " ";
+ position: absolute;
+
+ @media (max-width: $grid-breakpoints-md) {
+ display: none;
+ }
+ }
+
+ &:after {
+ @include border-right-color($white);
+ @include margin-left($baseline*-1+1);
+ border-width: $baseline/2;
+ }
+
+ &:before {
+ @include margin-left($baseline*-1);
+ @include border-right-color($lms-border-color);
+ border-width: $baseline/2;
+ }
+
+ .message-header {
+ font-weight: $font-semibold;
+ margin-bottom: $baseline/4;
+ }
+
+ a {
+ font-weight: $font-semibold;
+ text-decoration: underline;
+ }
+ }
+}
+
// Welcome message
.welcome-message {
border: solid 1px $lms-border-color;
diff --git a/lms/static/sass/shared-v2/_variables.scss b/lms/static/sass/shared-v2/_variables.scss
index 9603ac1136..e9e84343ff 100644
--- a/lms/static/sass/shared-v2/_variables.scss
+++ b/lms/static/sass/shared-v2/_variables.scss
@@ -11,6 +11,10 @@
// ----------------------------
$lms-max-width: 1180px !default;
+$grid-breakpoints-sm: 576px !default;
+$grid-breakpoints-md: 768px !default;
+$grid-breakpoints-lg: 992px !default;
+
// ----------------------------
// #COLORS
// ----------------------------
diff --git a/lms/templates/page_banner.html b/lms/templates/page_banner.html
index bf1e906107..5e6dddd653 100644
--- a/lms/templates/page_banner.html
+++ b/lms/templates/page_banner.html
@@ -7,11 +7,11 @@
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML
-from openedx.core.djangoapps.util.user_messages import user_messages
+from openedx.core.djangoapps.util.user_messages import PageLevelMessages
%>
<%
-banner_messages = list(user_messages(request))
+banner_messages = list(PageLevelMessages.user_messages(request))
%>
% if banner_messages:
diff --git a/lms/templates/ux/reference/index.html b/lms/templates/ux/reference/index.html
index a9d1b8de3b..1dd042c83c 100644
--- a/lms/templates/ux/reference/index.html
+++ b/lms/templates/ux/reference/index.html
@@ -1,5 +1,8 @@
## mako
+## Override the default styles_version to use Bootstrap
+<%! main_css = "css/bootstrap/lms-main.css" %>
+
<%page expression_filter="h"/>
<%inherit file="/main.html" />
diff --git a/openedx/core/djangoapps/bookmarks/tests/test_views.py b/openedx/core/djangoapps/bookmarks/tests/test_views.py
index e8344f70ec..30ce17cc2d 100644
--- a/openedx/core/djangoapps/bookmarks/tests/test_views.py
+++ b/openedx/core/djangoapps/bookmarks/tests/test_views.py
@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase):
self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.')
# Send empty data dictionary.
- with self.assertNumQueries(8): # No queries for bookmark table.
+ with self.assertNumQueries(7): # No queries for bookmark table.
response = self.send_post(
client=self.client,
url=reverse('bookmarks'),
diff --git a/openedx/core/djangoapps/catalog/management/commands/cache_programs.py b/openedx/core/djangoapps/catalog/management/commands/cache_programs.py
index ec4f521d40..6037acbeb6 100644
--- a/openedx/core/djangoapps/catalog/management/commands/cache_programs.py
+++ b/openedx/core/djangoapps/catalog/management/commands/cache_programs.py
@@ -50,6 +50,7 @@ class Command(BaseCommand):
site_config = getattr(site, 'configuration', None)
if site_config is None or not site_config.get_value('COURSE_CATALOG_API_URL'):
logger.info('Skipping site {domain}. No configuration.'.format(domain=site.domain))
+ cache.set(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), [], None)
continue
client = create_catalog_api_client(user, site=site)
diff --git a/openedx/core/djangoapps/catalog/tests/test_utils.py b/openedx/core/djangoapps/catalog/tests/test_utils.py
index a27bca56e2..d02166a658 100644
--- a/openedx/core/djangoapps/catalog/tests/test_utils.py
+++ b/openedx/core/djangoapps/catalog/tests/test_utils.py
@@ -1,13 +1,13 @@
"""Tests covering utilities for integrating with the catalog service."""
# pylint: disable=missing-docstring
import copy
-import uuid
import ddt
import mock
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase, override_settings
+from student.tests.factories import UserFactory
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, PROGRAM_UUIDS_CACHE_KEY
from openedx.core.djangoapps.catalog.models import CatalogIntegration
@@ -19,8 +19,8 @@ from openedx.core.djangoapps.catalog.utils import (
get_programs,
get_programs_with_type
)
+from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
-from student.tests.factories import UserFactory
UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils'
User = get_user_model() # pylint: disable=invalid-name
@@ -32,6 +32,10 @@ User = get_user_model() # pylint: disable=invalid-name
class TestGetPrograms(CacheIsolationTestCase):
ENABLED_CACHES = ['default']
+ def setUp(self):
+ super(TestGetPrograms, self).setUp()
+ self.site = SiteFactory()
+
def test_get_many(self, mock_warning, mock_info):
programs = ProgramFactory.create_batch(3)
@@ -43,7 +47,7 @@ class TestGetPrograms(CacheIsolationTestCase):
# When called before UUIDs are cached, the function should return an
# empty list and log a warning.
- self.assertEqual(get_programs(), [])
+ self.assertEqual(get_programs(self.site), [])
mock_warning.assert_called_once_with('Failed to get program UUIDs from the cache.')
mock_warning.reset_mock()
@@ -54,7 +58,7 @@ class TestGetPrograms(CacheIsolationTestCase):
None
)
- actual_programs = get_programs()
+ actual_programs = get_programs(self.site)
# The 2 cached programs should be returned while info and warning
# messages should be logged for the missing one.
@@ -82,7 +86,7 @@ class TestGetPrograms(CacheIsolationTestCase):
}
cache.set_many(all_programs, None)
- actual_programs = get_programs()
+ actual_programs = get_programs(self.site)
# All 3 programs should be returned.
self.assertEqual(
@@ -116,7 +120,7 @@ class TestGetPrograms(CacheIsolationTestCase):
mock_cache.get.return_value = [program['uuid'] for program in programs]
mock_cache.get_many.side_effect = fake_get_many
- actual_programs = get_programs()
+ actual_programs = get_programs(self.site)
# All 3 cached programs should be returned. An info message should be
# logged about the one that was initially missing, but the code should
@@ -136,7 +140,7 @@ class TestGetPrograms(CacheIsolationTestCase):
expected_program = ProgramFactory()
expected_uuid = expected_program['uuid']
- self.assertEqual(get_programs(uuid=expected_uuid), None)
+ self.assertEqual(get_programs(self.site, uuid=expected_uuid), None)
mock_warning.assert_called_once_with(
'Failed to get details for program {uuid} from the cache.'.format(uuid=expected_uuid)
)
@@ -148,7 +152,7 @@ class TestGetPrograms(CacheIsolationTestCase):
None
)
- actual_program = get_programs(uuid=expected_uuid)
+ actual_program = get_programs(self.site, uuid=expected_uuid)
self.assertEqual(actual_program, expected_program)
self.assertFalse(mock_warning.called)
@@ -156,6 +160,9 @@ class TestGetPrograms(CacheIsolationTestCase):
@skip_unless_lms
@ddt.ddt
class TestGetProgramsWithType(TestCase):
+ def setUp(self):
+ super(TestGetProgramsWithType, self).setUp()
+ self.site = SiteFactory()
@mock.patch(UTILS_MODULE + '.get_programs')
@mock.patch(UTILS_MODULE + '.get_program_types')
@@ -176,7 +183,7 @@ class TestGetProgramsWithType(TestCase):
mock_get_programs.return_value = programs
mock_get_program_types.return_value = program_types
- actual = get_programs_with_type()
+ actual = get_programs_with_type(self.site)
self.assertEqual(actual, programs_with_program_type)
@ddt.data(False, True)
@@ -202,7 +209,7 @@ class TestGetProgramsWithType(TestCase):
mock_get_programs.return_value = programs
mock_get_program_types.return_value = program_types
- actual = get_programs_with_type(include_hidden=include_hidden)
+ actual = get_programs_with_type(self.site, include_hidden=include_hidden)
self.assertEqual(actual, programs_with_program_type)
diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py
index 9d0b4d24f4..6ba15dd028 100644
--- a/openedx/core/djangoapps/catalog/utils.py
+++ b/openedx/core/djangoapps/catalog/utils.py
@@ -14,7 +14,6 @@ from openedx.core.djangoapps.catalog.cache import (
SITE_PROGRAM_UUIDS_CACHE_KEY_TPL
)
from openedx.core.djangoapps.catalog.models import CatalogIntegration
-from openedx.core.djangoapps.theming.helpers import get_current_site
from openedx.core.lib.edx_api_utils import get_edx_api_data
from openedx.core.lib.token_utils import JwtBuilder
@@ -35,11 +34,14 @@ def create_catalog_api_client(user, site=None):
return EdxRestApiClient(url, jwt=jwt)
-def get_programs(uuid=None):
+def get_programs(site, uuid=None):
"""Read programs from the cache.
The cache is populated by a management command, cache_programs.
+ Arguments:
+ site (Site): django.contrib.sites.models object
+
Keyword Arguments:
uuid (string): UUID identifying a specific program to read from the cache.
@@ -56,7 +58,7 @@ def get_programs(uuid=None):
return program
if waffle.switch_is_active('get-multitenant-programs'):
- uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=get_current_site().domain), [])
+ uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), [])
else:
uuids = cache.get(PROGRAM_UUIDS_CACHE_KEY, [])
if not uuids:
@@ -121,13 +123,16 @@ def get_program_types(name=None):
return []
-def get_programs_with_type(include_hidden=True):
+def get_programs_with_type(site, include_hidden=True):
"""
Return the list of programs. You can filter the types of programs returned by using the optional
include_hidden parameter. By default hidden programs will be included.
The program dict is updated with the fully serialized program type.
+ Arguments:
+ site (Site): django.contrib.sites.models object
+
Keyword Arguments:
include_hidden (bool): whether to include hidden programs
@@ -135,7 +140,7 @@ def get_programs_with_type(include_hidden=True):
list of dict, representing the active programs.
"""
programs_with_type = []
- programs = get_programs()
+ programs = get_programs(site)
if programs:
program_types = {program_type['name']: program_type for program_type in get_program_types()}
diff --git a/openedx/core/djangoapps/debug/views.py b/openedx/core/djangoapps/debug/views.py
index 983ecda81a..ba2d200a2a 100644
--- a/openedx/core/djangoapps/debug/views.py
+++ b/openedx/core/djangoapps/debug/views.py
@@ -7,12 +7,7 @@ from django.http import HttpResponseNotFound
from django.utils.translation import ugettext as _
from edxmako.shortcuts import render_to_response
from mako.exceptions import TopLevelLookupException
-from openedx.core.djangoapps.util.user_messages import (
- register_error_message,
- register_info_message,
- register_success_message,
- register_warning_message,
-)
+from openedx.core.djangoapps.util.user_messages import PageLevelMessages
def show_reference_template(request, template):
@@ -28,23 +23,34 @@ def show_reference_template(request, template):
e.g. /template/ux/reference/index.html?name=Foo
"""
try:
- uses_bootstrap = u'/bootstrap/' in request.path
uses_pattern_library = u'/pattern-library/' in request.path
is_v1 = u'/v1/' in request.path
+ uses_bootstrap = not uses_pattern_library and not is_v1
context = {
- "disable_courseware_js": not is_v1,
- "uses_pattern_library": uses_pattern_library,
- "uses_bootstrap": uses_bootstrap,
+ 'request': request,
+ 'disable_courseware_js': not is_v1,
+ 'uses_pattern_library': uses_pattern_library,
+ 'uses_bootstrap': uses_bootstrap,
}
context.update(request.GET.dict())
+ # Support dynamic rendering of messages
+ if request.GET.get('alert'):
+ register_info_message(request, request.GET.get('alert'))
+ if request.GET.get('success'):
+ register_success_message(request, request.GET.get('success'))
+ if request.GET.get('warning'):
+ register_warning_message(request, request.GET.get('warning'))
+ if request.GET.get('error'):
+ register_error_message(request, request.GET.get('error'))
+
# Add some messages to the course skeleton pages
if u'course-skeleton.html' in request.path:
- register_info_message(request, _('This is a test message'))
- register_success_message(request, _('This is a success message'))
- register_warning_message(request, _('This is a test warning'))
- register_error_message(request, _('This is a test error'))
+ PageLevelMessages.register_info_message(request, _('This is a test message'))
+ PageLevelMessages.register_success_message(request, _('This is a success message'))
+ PageLevelMessages.register_warning_message(request, _('This is a test warning'))
+ PageLevelMessages.register_error_message(request, _('This is a test error'))
return render_to_response(template, context)
except TopLevelLookupException:
- return HttpResponseNotFound("Couldn't find template {template}".format(template=template))
+ return HttpResponseNotFound('Missing template {template}'.format(template=template))
diff --git a/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py b/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py
index c9cb763fd2..61121ae77e 100644
--- a/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py
+++ b/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py
@@ -1,18 +1,17 @@
"""Management command for backpopulating missing program credentials."""
-from collections import namedtuple
import logging
+from collections import namedtuple
-from django.contrib.auth.models import User
+from django.contrib.sites.models import Site
from django.core.management import BaseCommand
from django.db.models import Q
from opaque_keys.edx.keys import CourseKey
-from certificates.models import GeneratedCertificate, CertificateStatuses # pylint: disable=import-error
+from certificates.models import CertificateStatuses, GeneratedCertificate # pylint: disable=import-error
from course_modes.models import CourseMode
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates
-
# TODO: Log to console, even with debug mode disabled?
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
CourseRun = namedtuple('CourseRun', ['key', 'type'])
@@ -73,7 +72,11 @@ class Command(BaseCommand):
def _load_course_runs(self):
"""Find all course runs which are part of a program."""
- programs = get_programs()
+ programs = []
+ for site in Site.objects.all():
+ logger.info('Loading programs from the catalog for site %s.', site.domain)
+ programs.extend(get_programs(site))
+
self.course_runs = self._flatten(programs)
def _flatten(self, programs):
diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py
index 81e27163c9..8570401236 100644
--- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py
+++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py
@@ -5,6 +5,7 @@ from celery import task
from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error
from django.conf import settings
from django.contrib.auth.models import User
+from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured
from edx_rest_api_client import exceptions
from edx_rest_api_client.client import EdxRestApiClient
@@ -55,18 +56,19 @@ def get_api_client(api_config, student):
return EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
-def get_completed_programs(student):
+def get_completed_programs(site, student):
"""
Given a set of completed courses, determine which programs are completed.
Args:
+ site (Site): Site for which data should be retrieved.
student (User): Representing the student whose completed programs to check for.
Returns:
list of program UUIDs
"""
- meter = ProgramProgressMeter(student)
+ meter = ProgramProgressMeter(site, student)
return meter.completed_programs
@@ -80,7 +82,7 @@ def get_certified_programs(student):
User object representing the student
Returns:
- UUIDs of the programs for which the student has been awarded a certificate
+ str[]: UUIDs of the programs for which the student has been awarded a certificate
"""
certified_programs = []
@@ -129,8 +131,7 @@ def award_program_certificates(self, username):
student.
Args:
- username:
- The username of the student
+ username (str): The username of the student
Returns:
None
@@ -158,16 +159,16 @@ def award_program_certificates(self, username):
LOGGER.exception('Task award_program_certificates was called with invalid username %s', username)
# Don't retry for this case - just conclude the task.
return
-
- program_uuids = get_completed_programs(student)
+ program_uuids = []
+ for site in Site.objects.all():
+ program_uuids.extend(get_completed_programs(site, student))
if not program_uuids:
# No reason to continue beyond this point unless/until this
# task gets updated to support revocation of program certs.
LOGGER.info('Task award_program_certificates was called for user %s with no completed programs', username)
return
- # Determine which program certificates the user has already been
- # awarded, if any.
+ # Determine which program certificates the user has already been awarded, if any.
existing_program_uuids = get_certified_programs(student)
except Exception as exc: # pylint: disable=broad-except
diff --git a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py
index 1960bb4e42..a2dfb2788d 100644
--- a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py
+++ b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py
@@ -16,6 +16,7 @@ from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangoapps.programs.tasks.v1 import tasks
+from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory
@@ -130,6 +131,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
super(AwardProgramCertificatesTestCase, self).setUp()
self.create_credentials_config()
self.student = UserFactory.create(username='test-student')
+ self.site = SiteFactory()
self.catalog_integration = self.create_catalog_integration()
ClientFactory.create(name='credentials')
@@ -146,7 +148,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
programs.
"""
tasks.award_program_certificates.delay(self.student.username).get()
- mock_get_completed_programs.assert_called_once_with(self.student)
+ mock_get_completed_programs.assert_called(self.site, self.student)
@ddt.data(
([1], [2, 3]),
@@ -282,7 +284,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
mock_get_completed_programs.side_effect = self._make_side_effect([Exception('boom'), None])
tasks.award_program_certificates.delay(self.student.username).get()
- self.assertEqual(mock_get_completed_programs.call_count, 2)
+ self.assertEqual(mock_get_completed_programs.call_count, 3)
def test_retry_on_credentials_api_errors(
self,
diff --git a/openedx/core/djangoapps/programs/tests/test_signals.py b/openedx/core/djangoapps/programs/tests/test_signals.py
index 30950b27c5..94c4845bde 100644
--- a/openedx/core/djangoapps/programs/tests/test_signals.py
+++ b/openedx/core/djangoapps/programs/tests/test_signals.py
@@ -10,8 +10,8 @@ from student.tests.factories import UserFactory
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded
-from openedx.core.djangolib.testing.utils import skip_unless_lms
-
+from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
+from openedx.core.djangolib.testing.utils import skip_unless_lms, get_mock_request
TEST_USERNAME = 'test-user'
diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py
index 0b5fbe2019..582d051024 100644
--- a/openedx/core/djangoapps/programs/tests/test_utils.py
+++ b/openedx/core/djangoapps/programs/tests/test_utils.py
@@ -33,6 +33,7 @@ from openedx.core.djangoapps.programs.utils import (
ProgramMarketingDataExtender,
get_certificates,
)
+from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import AnonymousUserFactory, UserFactory, CourseEnrollmentFactory
from util.date_utils import strftime_localized
@@ -55,6 +56,7 @@ class TestProgramProgressMeter(TestCase):
super(TestProgramProgressMeter, self).setUp()
self.user = UserFactory()
+ self.site = SiteFactory()
def _create_enrollments(self, *course_run_ids):
"""Variadic helper used to create course run enrollments."""
@@ -92,7 +94,7 @@ class TestProgramProgressMeter(TestCase):
data = [ProgramFactory()]
mock_get_programs.return_value = data
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter)
@@ -104,7 +106,7 @@ class TestProgramProgressMeter(TestCase):
course_run_id = generate_course_run_key()
self._create_enrollments(course_run_id)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter)
@@ -129,7 +131,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = data
self._create_enrollments(course_run_key)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._attach_detail_url(data)
program = data[0]
@@ -159,7 +161,7 @@ class TestProgramProgressMeter(TestCase):
self._create_enrollments(course_run_key)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
program = data[0]
expected = [
@@ -195,7 +197,7 @@ class TestProgramProgressMeter(TestCase):
mode=CourseMode.NO_ID_PROFESSIONAL_MODE
)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
program = data[0]
expected = [
@@ -236,7 +238,7 @@ class TestProgramProgressMeter(TestCase):
CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit')
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
program = data[0]
expected = [
@@ -278,7 +280,7 @@ class TestProgramProgressMeter(TestCase):
# The creation time of the enrollments matters to the test. We want
# the first_course_run_key to represent the newest enrollment.
self._create_enrollments(older_course_run_key, newer_course_run_key)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._attach_detail_url(data)
programs = data[:2]
@@ -323,7 +325,7 @@ class TestProgramProgressMeter(TestCase):
# Enrollment for the shared course run created last (most recently).
self._create_enrollments(solo_course_run_key, shared_course_run_key)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._attach_detail_url(data)
programs = data[:3]
@@ -354,13 +356,13 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = data
# No enrollments, no programs in progress.
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
# One enrollment, one program in progress.
self._create_enrollments(first_course_run_key)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
program, program_uuid = data[0], data[0]['uuid']
self._assert_progress(
meter,
@@ -370,7 +372,7 @@ class TestProgramProgressMeter(TestCase):
# Two enrollments, all courses in progress.
self._create_enrollments(second_course_run_key)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, in_progress=2)
@@ -381,7 +383,7 @@ class TestProgramProgressMeter(TestCase):
mock_completed_course_runs.return_value = [
{'course_run_id': first_course_run_key, 'type': MODES.verified},
]
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
@@ -393,7 +395,7 @@ class TestProgramProgressMeter(TestCase):
{'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_course_run_key, 'type': MODES.honor},
]
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
@@ -405,7 +407,7 @@ class TestProgramProgressMeter(TestCase):
{'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_course_run_key, 'type': MODES.verified},
]
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(
meter,
ProgressFactory(uuid=program_uuid, completed=2)
@@ -436,7 +438,7 @@ class TestProgramProgressMeter(TestCase):
mock_completed_course_runs.return_value = [
{'course_run_id': course_run_key, 'type': MODES.honor},
]
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
program, program_uuid = data[0], data[0]['uuid']
self._assert_progress(
@@ -449,7 +451,7 @@ class TestProgramProgressMeter(TestCase):
"""Verify that programs with no courses do not count as completed."""
program = ProgramFactory()
program['courses'] = []
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
program_complete = meter._is_program_complete(program)
self.assertFalse(program_complete)
@@ -469,7 +471,7 @@ class TestProgramProgressMeter(TestCase):
course_run_keys.append(course_run['key'])
# Verify that no programs are complete.
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [])
# Complete all programs.
@@ -480,7 +482,7 @@ class TestProgramProgressMeter(TestCase):
]
# Verify that all programs are complete.
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, program_uuids)
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
@@ -494,7 +496,7 @@ class TestProgramProgressMeter(TestCase):
self._make_certificate_result(status='unknown', course_key='unknown-course'),
]
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(
meter.completed_course_runs,
[
@@ -517,7 +519,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = [program]
# Verify that the test program is not complete.
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [])
# Grant a 'no-id-professional' certificate for one of the course runs,
@@ -527,7 +529,7 @@ class TestProgramProgressMeter(TestCase):
]
# Verify that the program is complete.
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [program['uuid']])
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
@@ -543,7 +545,7 @@ class TestProgramProgressMeter(TestCase):
program = ProgramFactory(courses=[course])
mock_get_programs.return_value = [program]
self._create_enrollments(course_run_key)
- meter = ProgramProgressMeter(self.user)
+ meter = ProgramProgressMeter(self.site, self.user)
mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': 'verified'}]
self.assertEqual(meter._is_course_complete(course), True)
diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py
index c663ac3bbc..7619d2c4d1 100644
--- a/openedx/core/djangoapps/programs/utils.py
+++ b/openedx/core/djangoapps/programs/utils.py
@@ -70,7 +70,8 @@ class ProgramProgressMeter(object):
will only inspect this one program, not all programs the user may be
engaged with.
"""
- def __init__(self, user, enrollments=None, uuid=None):
+ def __init__(self, site, user, enrollments=None, uuid=None):
+ self.site = site
self.user = user
self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
@@ -89,9 +90,9 @@ class ProgramProgressMeter(object):
self.course_run_ids.append(enrollment_id)
if uuid:
- self.programs = [get_programs(uuid=uuid)]
+ self.programs = [get_programs(self.site, uuid=uuid)]
else:
- self.programs = attach_program_detail_url(get_programs())
+ self.programs = attach_program_detail_url(get_programs(self.site))
def invert_programs(self):
"""Intersect programs and enrollments.
diff --git a/openedx/core/djangoapps/site_configuration/tests/factories.py b/openedx/core/djangoapps/site_configuration/tests/factories.py
index f9277d1a41..0ce22f5e74 100644
--- a/openedx/core/djangoapps/site_configuration/tests/factories.py
+++ b/openedx/core/djangoapps/site_configuration/tests/factories.py
@@ -26,5 +26,7 @@ class SiteFactory(DjangoModelFactory):
model = Site
django_get_or_create = ('domain',)
+ # TODO These should be generated. Otherwise, code that creates multiple Site
+ # objects will only end up with a single Site since domain has a unique constraint.
domain = 'testserver.fake'
name = 'testserver.fake'
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
index 443dbe046b..dd6f2dd017 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
@@ -174,7 +174,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
Test that a client (logged in) can get her own username.
"""
self.client.login(username=self.user.username, password=TEST_PASSWORD)
- self._verify_get_own_username(15)
+ self._verify_get_own_username(14)
def test_get_username_inactive(self):
"""
@@ -184,7 +184,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.user.is_active = False
self.user.save()
- self._verify_get_own_username(15)
+ self._verify_get_own_username(14)
def test_get_username_not_logged_in(self):
"""
@@ -193,7 +193,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
"""
# verify that the endpoint is inaccessible when not logged in
- self._verify_get_own_username(13, expected_status=401)
+ self._verify_get_own_username(12, expected_status=401)
@ddt.ddt
@@ -305,7 +305,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
- with self.assertNumQueries(19):
+ with self.assertNumQueries(18):
response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
@@ -320,7 +320,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
- with self.assertNumQueries(19):
+ with self.assertNumQueries(18):
response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
@@ -395,12 +395,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=TEST_PASSWORD)
- verify_get_own_information(17)
+ verify_get_own_information(16)
# Now make sure that the user can get the same information, even if not active
self.user.is_active = False
self.user.save()
- verify_get_own_information(11)
+ verify_get_own_information(10)
def test_get_account_empty_string(self):
"""
@@ -414,7 +414,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.save()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
- with self.assertNumQueries(17):
+ with self.assertNumQueries(16):
response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country", "bio"):
self.assertIsNone(response.data[empty_field])
diff --git a/openedx/core/djangoapps/util/tests/test_user_messages.py b/openedx/core/djangoapps/util/tests/test_user_messages.py
index 6be513578c..f4376c7b80 100644
--- a/openedx/core/djangoapps/util/tests/test_user_messages.py
+++ b/openedx/core/djangoapps/util/tests/test_user_messages.py
@@ -10,15 +10,7 @@ from django.test import RequestFactory
from openedx.core.djangolib.markup import HTML, Text
from student.tests.factories import UserFactory
-from ..user_messages import (
- register_error_message,
- register_info_message,
- register_success_message,
- register_user_message,
- register_warning_message,
- user_messages,
- UserMessageType,
-)
+from ..user_messages import PageLevelMessages, UserMessageType
TEST_MESSAGE = 'Test message'
@@ -26,7 +18,7 @@ TEST_MESSAGE = 'Test message'
@ddt.ddt
class UserMessagesTestCase(TestCase):
"""
- Unit tests for user messages.
+ Unit tests for page level user messages.
"""
def setUp(self):
super(UserMessagesTestCase, self).setUp()
@@ -46,8 +38,8 @@ class UserMessagesTestCase(TestCase):
"""
Verifies that a user message is escaped correctly.
"""
- register_user_message(self.request, UserMessageType.INFO, message)
- messages = list(user_messages(self.request))
+ PageLevelMessages.register_user_message(self.request, UserMessageType.INFO, message)
+ messages = list(PageLevelMessages.user_messages(self.request))
self.assertEqual(len(messages), 1)
self.assertEquals(messages[0].message_html, expected_message_html)
@@ -62,17 +54,17 @@ class UserMessagesTestCase(TestCase):
"""
Verifies that a user message returns the correct CSS and icon classes.
"""
- register_user_message(self.request, message_type, TEST_MESSAGE)
- messages = list(user_messages(self.request))
+ PageLevelMessages.register_user_message(self.request, message_type, TEST_MESSAGE)
+ messages = list(PageLevelMessages.user_messages(self.request))
self.assertEqual(len(messages), 1)
self.assertEquals(messages[0].css_class, expected_css_class)
self.assertEquals(messages[0].icon_class, expected_icon_class)
@ddt.data(
- (register_error_message, UserMessageType.ERROR),
- (register_info_message, UserMessageType.INFO),
- (register_success_message, UserMessageType.SUCCESS),
- (register_warning_message, UserMessageType.WARNING),
+ (PageLevelMessages.register_error_message, UserMessageType.ERROR),
+ (PageLevelMessages.register_info_message, UserMessageType.INFO),
+ (PageLevelMessages.register_success_message, UserMessageType.SUCCESS),
+ (PageLevelMessages.register_warning_message, UserMessageType.WARNING),
)
@ddt.unpack
def test_message_type(self, register_message_function, expected_message_type):
@@ -80,6 +72,6 @@ class UserMessagesTestCase(TestCase):
Verifies that each user message function returns the correct type.
"""
register_message_function(self.request, TEST_MESSAGE)
- messages = list(user_messages(self.request))
+ messages = list(PageLevelMessages.user_messages(self.request))
self.assertEqual(len(messages), 1)
self.assertEquals(messages[0].type, expected_message_type)
diff --git a/openedx/core/djangoapps/util/user_messages.py b/openedx/core/djangoapps/util/user_messages.py
index 251b4e9339..961d6288d4 100644
--- a/openedx/core/djangoapps/util/user_messages.py
+++ b/openedx/core/djangoapps/util/user_messages.py
@@ -14,12 +14,12 @@ There are two common use cases:
used to show a success message to the use.
"""
+from abc import abstractmethod
from enum import Enum
from django.contrib import messages
-from openedx.core.djangolib.markup import Text
-
-EDX_USER_MESSAGE_TAG = 'edx-user-message'
+from django.utils.translation import ugettext as _
+from openedx.core.djangolib.markup import Text, HTML
class UserMessageType(Enum):
@@ -49,7 +49,7 @@ ICON_CLASSES = {
class UserMessage():
"""
- Representation of a message to be shown to a user
+ Representation of a message to be shown to a user.
"""
def __init__(self, type, message_html):
assert isinstance(type, UserMessageType)
@@ -67,71 +67,124 @@ class UserMessage():
def icon_class(self):
"""
Returns the CSS icon class representing the message type.
- Returns:
"""
return ICON_CLASSES[self.type]
-def register_user_message(request, message_type, message, title=None):
+class UserMessageCollection():
"""
- Register a message to be shown to the user in the next page.
+ A collection of messages to be shown to a user.
"""
- assert isinstance(message_type, UserMessageType)
- messages.add_message(request, message_type.value, Text(message), extra_tags=EDX_USER_MESSAGE_TAG)
-
-
-def register_info_message(request, message, **kwargs):
- """
- Registers an information message to be shown to the user.
- """
- register_user_message(request, UserMessageType.INFO, message, **kwargs)
-
-
-def register_success_message(request, message, **kwargs):
- """
- Registers a success message to be shown to the user.
- """
- register_user_message(request, UserMessageType.SUCCESS, message, **kwargs)
-
-
-def register_warning_message(request, message, **kwargs):
- """
- Registers a warning message to be shown to the user.
- """
- register_user_message(request, UserMessageType.WARNING, message, **kwargs)
-
-
-def register_error_message(request, message, **kwargs):
- """
- Registers an error message to be shown to the user.
- """
- register_user_message(request, UserMessageType.ERROR, message, **kwargs)
-
-
-def user_messages(request):
- """
- Returns any outstanding user messages.
-
- Note: this function also marks these messages as being complete
- so they won't be returned in the next request.
- """
- def _get_message_type_for_level(level):
+ @classmethod
+ @abstractmethod
+ def get_namespace(self):
"""
- Returns the user message type associated with a level.
- """
- for __, type in UserMessageType.__members__.items():
- if type.value is level:
- return type
- raise 'Unable to find UserMessageType for level {level}'.format(level=level)
+ Returns the namespace of the message collection.
- def _create_user_message(message):
+ The name is used to namespace the subset of django messages.
+ For example, return 'course_home_messages'.
"""
- Creates a user message from a Django message.
- """
- return UserMessage(
- type=_get_message_type_for_level(message.level),
- message_html=unicode(message.message),
- )
+ raise NotImplementedError('Subclasses must define a namespace for messages.')
- django_messages = messages.get_messages(request)
- return (_create_user_message(message) for message in django_messages if EDX_USER_MESSAGE_TAG in message.tags)
+ @classmethod
+ def get_message_html(self, body_html, title=None):
+ """
+ Returns the entire HTML snippet for the message.
+
+ Classes that extend this base class can override the message styling
+ by implementing their own version of this function. Messages that do
+ not use a title can just pass the body_html.
+ """
+ if title:
+ return Text(_('{header_open}{title}{header_close}{body}')).format(
+ header_open=HTML('')
+ )
+ return body_html
+
+ @classmethod
+ def register_user_message(self, request, message_type, body_html, title=None):
+ """
+ Register a message to be shown to the user in the next page.
+
+ Arguments:
+ message_type (UserMessageType): the user message type
+ body_html (str): body of the message in html format
+ title (str): optional title for the message as plain text
+ """
+ assert isinstance(message_type, UserMessageType)
+ message = Text(self.get_message_html(body_html, title))
+ messages.add_message(request, message_type.value, Text(message), extra_tags=self.get_namespace())
+
+ @classmethod
+ def register_info_message(self, request, message, **kwargs):
+ """
+ Registers an information message to be shown to the user.
+ """
+ self.register_user_message(request, UserMessageType.INFO, message, **kwargs)
+
+ @classmethod
+ def register_success_message(self, request, message, **kwargs):
+ """
+ Registers a success message to be shown to the user.
+ """
+ self.register_user_message(request, UserMessageType.SUCCESS, message, **kwargs)
+
+ @classmethod
+ def register_warning_message(self, request, message, **kwargs):
+ """
+ Registers a warning message to be shown to the user.
+ """
+ self.register_user_message(request, UserMessageType.WARNING, message, **kwargs)
+
+ @classmethod
+ def register_error_message(self, request, message, **kwargs):
+ """
+ Registers an error message to be shown to the user.
+ """
+ self.register_user_message(request, UserMessageType.ERROR, message, **kwargs)
+
+ @classmethod
+ def user_messages(self, request):
+ """
+ Returns any outstanding user messages.
+
+ Note: this function also marks these messages as being complete
+ so they won't be returned in the next request.
+ """
+ def _get_message_type_for_level(level):
+ """
+ Returns the user message type associated with a level.
+ """
+ for __, type in UserMessageType.__members__.items():
+ if type.value is level:
+ return type
+ raise 'Unable to find UserMessageType for level {level}'.format(level=level)
+
+ def _create_user_message(message):
+ """
+ Creates a user message from a Django message.
+ """
+ return UserMessage(
+ type=_get_message_type_for_level(message.level),
+ message_html=unicode(message.message),
+ )
+
+ django_messages = messages.get_messages(request)
+ return (_create_user_message(message) for message in django_messages if self.get_namespace() in message.tags)
+
+
+class PageLevelMessages(UserMessageCollection):
+ """
+ This set of messages appears as top page level messages.
+ """
+ NAMESPACE = 'page_level_messages'
+
+ @classmethod
+ def get_namespace(self):
+ """
+ Returns the namespace of the message collection.
+ """
+ return self.NAMESPACE
diff --git a/openedx/features/course_bookmarks/plugins.py b/openedx/features/course_bookmarks/plugins.py
index 95e73f0af8..bb8921823c 100644
--- a/openedx/features/course_bookmarks/plugins.py
+++ b/openedx/features/course_bookmarks/plugins.py
@@ -13,6 +13,13 @@ class CourseBookmarksTool(CourseTool):
"""
The course bookmarks tool.
"""
+ @classmethod
+ def analytics_id(cls):
+ """
+ Returns an id to uniquely identify this tool in analytics events.
+ """
+ return 'edx.bookmarks'
+
@classmethod
def is_enabled(cls, request, course_key):
"""
diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py
index 578c005e00..1946dc8c72 100644
--- a/openedx/features/course_experience/__init__.py
+++ b/openedx/features/course_experience/__init__.py
@@ -3,7 +3,8 @@ Unified course experience settings and helper methods.
"""
from django.utils.translation import ugettext as _
-from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlag, WaffleFlagNamespace
+from openedx.core.djangoapps.util.user_messages import UserMessageCollection
+from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace
# Namespace for course experience waffle flags.
@@ -58,3 +59,17 @@ def course_home_url_name(course_key):
return 'openedx.course_experience.course_home'
else:
return 'info'
+
+
+class CourseHomeMessages(UserMessageCollection):
+ """
+ This set of messages appear above the outline on the course home page.
+ """
+ NAMESPACE = 'course_home_level_messages'
+
+ @classmethod
+ def get_namespace(self):
+ """
+ Returns the namespace of the message collection.
+ """
+ return self.NAMESPACE
diff --git a/openedx/features/course_experience/course_tools.py b/openedx/features/course_experience/course_tools.py
index 82fe4be708..3be140ecc3 100644
--- a/openedx/features/course_experience/course_tools.py
+++ b/openedx/features/course_experience/course_tools.py
@@ -16,6 +16,14 @@ class CourseTool(object):
not a requirement, and plugin implementations outside of this repo should
simply follow the contract defined below.
"""
+ @classmethod
+ def analytics_id(cls):
+ """
+ Returns an id to uniquely identify this tool in analytics events.
+
+ For example, 'edx.bookmarks'. New tools may warrant doc updates for the new id.
+ """
+ raise NotImplementedError("Must specify an id to enable course tool eventing.")
@classmethod
def is_enabled(cls, request, course_key):
diff --git a/openedx/features/course_experience/plugins.py b/openedx/features/course_experience/plugins.py
index e2aa0fe2bf..a47cf7eb4d 100644
--- a/openedx/features/course_experience/plugins.py
+++ b/openedx/features/course_experience/plugins.py
@@ -19,6 +19,13 @@ class CourseUpdatesTool(CourseTool):
"""
The course updates tool.
"""
+ @classmethod
+ def analytics_id(cls):
+ """
+ Returns an analytics id for this tool, used for eventing.
+ """
+ return 'edx.updates'
+
@classmethod
def title(cls):
"""
@@ -57,6 +64,13 @@ class CourseReviewsTool(CourseTool):
"""
The course reviews tool.
"""
+ @classmethod
+ def analytics_id(cls):
+ """
+ Returns an id to uniquely identify this tool in analytics events.
+ """
+ return 'edx.reviews'
+
@classmethod
def title(cls):
"""
diff --git a/openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html
index 42fdd71c75..f4a255ec87 100644
--- a/openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html
+++ b/openedx/features/course_experience/static/course_experience/fixtures/course-home-fragment.html
@@ -67,19 +67,19 @@
Course Tools
-
+
Bookmarks
-
+
Reviews
-
+
Updates
diff --git a/openedx/features/course_experience/static/course_experience/images/home_message_author.png b/openedx/features/course_experience/static/course_experience/images/home_message_author.png
new file mode 100644
index 0000000000..1b6e095a47
Binary files /dev/null and b/openedx/features/course_experience/static/course_experience/images/home_message_author.png differ
diff --git a/openedx/features/course_experience/static/course_experience/js/CourseHome.js b/openedx/features/course_experience/static/course_experience/js/CourseHome.js
index cedc8701b8..274bddda50 100644
--- a/openedx/features/course_experience/static/course_experience/js/CourseHome.js
+++ b/openedx/features/course_experience/static/course_experience/js/CourseHome.js
@@ -4,13 +4,12 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export
constructor(options) {
// Logging for course tool click events
const $courseToolLink = $(options.courseToolLink);
- $courseToolLink.on('click', () => {
- const courseToolName = document.querySelector('.course-tool-link').text.trim().toLowerCase();
+ $courseToolLink.on('click', (event) => {
+ const courseToolName = event.srcElement.dataset['analytics-id']; // eslint-disable-line dot-notation
Logger.log(
'edx.course.tool.accessed',
{
tool_name: courseToolName,
- page: 'course_home',
},
);
});
diff --git a/openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js
index d9af744bee..1fb00e1e4f 100644
--- a/openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js
+++ b/openedx/features/course_experience/static/course_experience/js/spec/CourseHome_spec.js
@@ -15,15 +15,19 @@ describe('Course Home factory', () => {
});
it('sends an event when an course tool is clicked', () => {
- document.querySelector('.course-tool-link').dispatchEvent(new Event('click'));
- const courseToolName = document.querySelector('.course-tool-link').text.trim().toLowerCase();
- expect(Logger.log).toHaveBeenCalledWith(
- 'edx.course.tool.accessed',
- {
- tool_name: courseToolName,
- page: 'course_home',
- },
- );
+ const courseToolNames = document.querySelectorAll('.course-tool-link');
+ for (let i = 0; i < courseToolNames.length; i += 1) {
+ const courseToolName = courseToolNames[i].dataset['analytics-id']; // eslint-disable-line dot-notation
+ const event = new CustomEvent('click');
+ event.srcElement = { dataset: { 'analytics-id': courseToolName } };
+ courseToolNames[i].dispatchEvent(event);
+ expect(Logger.log).toHaveBeenCalledWith(
+ 'edx.course.tool.accessed',
+ {
+ tool_name: courseToolName,
+ },
+ );
+ }
});
});
});
diff --git a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html
index d68617a2d0..e8fb3d8dd8 100644
--- a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html
+++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html
@@ -57,6 +57,10 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
+ % if course_home_message_fragment:
+ ${HTML(course_home_message_fragment.body_html())}
+ % endif
+
% if welcome_message_fragment and UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id):
${HTML(welcome_message_fragment.body_html())}
@@ -74,7 +78,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
% for course_tool in course_tools:
-
+
${course_tool.title()}
diff --git a/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html b/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html
new file mode 100644
index 0000000000..1cf6f94bd1
--- /dev/null
+++ b/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html
@@ -0,0 +1,30 @@
+## mako
+
+<%page expression_filter="h"/>
+<%namespace name='static' file='../static_content.html'/>
+
+<%!
+from django.utils.translation import get_language_bidi
+from openedx.core.djangolib.markup import HTML
+from openedx.features.course_experience import CourseHomeMessages
+%>
+
+<%
+is_rtl = get_language_bidi()
+%>
+
+% if course_home_messages:
+ % for message in course_home_messages:
+
+ % if not is_rtl:
+
+ % endif
+
+ ${HTML(message.message_html)}
+
+ % if is_rtl:
+
+ % endif
+
+ % endfor
+% endif
diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py
index 02726c473c..a3a08bd9cb 100644
--- a/openedx/features/course_experience/tests/views/test_course_home.py
+++ b/openedx/features/course_experience/tests/views/test_course_home.py
@@ -2,10 +2,10 @@
"""
Tests for the course home page.
"""
-import datetime
+from datetime import datetime, timedelta
import ddt
import mock
-import pytz
+from pytz import UTC
from waffle.testutils import override_flag
from courseware.tests.factories import StaffFactory
@@ -31,6 +31,10 @@ TEST_CHAPTER_NAME = 'Test Chapter'
TEST_WELCOME_MESSAGE = 'Welcome! '
TEST_UPDATE_MESSAGE = 'Test Update! '
TEST_COURSE_UPDATES_TOOL = '/course/updates">'
+TEST_COURSE_HOME_MESSAGE = 'course-message'
+TEST_COURSE_HOME_MESSAGE_ANONYMOUS = '/login'
+TEST_COURSE_HOME_MESSAGE_UNENROLLED = 'Enroll now'
+TEST_COURSE_HOME_MESSAGE_PRE_START = 'Course starts in'
QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES
@@ -73,7 +77,12 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
# pylint: disable=super-method-not-called
with super(CourseHomePageTestCase, cls).setUpClassAndTestData():
with cls.store.default_store(ModuleStoreEnum.Type.split):
- cls.course = CourseFactory.create(org='edX', number='test', display_name='Test Course')
+ cls.course = CourseFactory.create(
+ org='edX',
+ number='test',
+ display_name='Test Course',
+ start=datetime.now(UTC) - timedelta(days=30),
+ )
with cls.store.bulk_operations(cls.course.id):
chapter = ItemFactory.create(
category='chapter',
@@ -92,6 +101,15 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
cls.user = UserFactory(password=TEST_PASSWORD)
CourseEnrollment.enroll(cls.user, cls.course.id)
+ def create_future_course(self, specific_date=None):
+ """
+ Creates and returns a course in the future.
+ """
+ return CourseFactory.create(
+ display_name='Test Future Course',
+ start=specific_date if specific_date else datetime.now(UTC) + timedelta(days=30),
+ )
+
class TestCourseHomePage(CourseHomePageTestCase):
def setUp(self):
@@ -142,7 +160,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
- with self.assertNumQueries(38, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
+ with self.assertNumQueries(37, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_home_url(self.course)
self.client.get(url)
@@ -152,18 +170,15 @@ class TestCourseHomePage(CourseHomePageTestCase):
"""
Verify that the course home page handles start dates correctly.
"""
- now = datetime.datetime.now(pytz.UTC)
- tomorrow = now + datetime.timedelta(days=1)
- self.course.start = tomorrow
-
# The course home page should 404 for a course starting in the future
- url = course_home_url(self.course)
+ future_course = self.create_future_course(datetime(2030, 1, 1, tzinfo=UTC))
+ url = course_home_url(future_course)
response = self.client.get(url)
self.assertRedirects(response, '/dashboard?notlive=Jan+01%2C+2030')
# With the Waffle flag enabled, the course should be visible
with override_flag(COURSE_PRE_START_ACCESS_FLAG.namespaced_flag_name, True):
- url = course_home_url(self.course)
+ url = course_home_url(future_course)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
@@ -272,11 +287,12 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
Ensure that a user accessing a non-live course sees a redirect to
the student dashboard, not a 404.
"""
- self.user = self.create_user_for_course(self.course, CourseUserType.ENROLLED)
+ future_course = self.create_future_course()
+ self.user = self.create_user_for_course(future_course, CourseUserType.ENROLLED)
- url = course_home_url(self.course)
+ url = course_home_url(future_course)
response = self.client.get(url)
- start_date = strftime_localized(self.course.start, 'SHORT_DATE')
+ start_date = strftime_localized(future_course.start, 'SHORT_DATE')
expected_params = QueryDict(mutable=True)
expected_params['notlive'] = start_date
expected_url = '{url}?{params}'.format(
@@ -292,12 +308,13 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
Ensure that a user accessing a non-live course sees a redirect to
the student dashboard, not a 404, even if the localized date is unicode
"""
- self.user = self.create_user_for_course(self.course, CourseUserType.ENROLLED)
+ future_course = self.create_future_course()
+ self.user = self.create_user_for_course(future_course, CourseUserType.ENROLLED)
fake_unicode_start_time = u"üñîçø∂é_ßtå®t_tîµé"
mock_strftime_localized.return_value = fake_unicode_start_time
- url = course_home_url(self.course)
+ url = course_home_url(future_course)
response = self.client.get(url)
expected_params = QueryDict(mutable=True)
expected_params['notlive'] = fake_unicode_start_time
@@ -316,3 +333,44 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
url = course_home_url_from_string('not/a/course')
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
+
+ @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
+ @override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
+ def test_course_messaging(self):
+ """
+ Ensure that the following four use cases work as expected
+
+ 1) Anonymous users are shown a course message linking them to the login page
+ 2) Unenrolled users are shown a course message allowing them to enroll
+ 3) Enrolled users who show up on the course page after the course has begun
+ are not shown a course message.
+ 4) Enrolled users who show up on the course page before the course begins
+ are shown a message explaining when the course starts as well as a call to
+ action button that allows them to add a calendar event.
+ """
+ # Verify that anonymous users are shown a login link in the course message
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE_ANONYMOUS)
+
+ # Verify that unenrolled users are shown an enroll call to action message
+ self.user = self.create_user_for_course(self.course, CourseUserType.UNENROLLED)
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED)
+
+ # Verify that enrolled users are not shown a message when enrolled and course has begun
+ CourseEnrollment.enroll(self.user, self.course.id)
+ url = course_home_url(self.course)
+ response = self.client.get(url)
+ self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE)
+
+ # Verify that enrolled users are shown 'days until start' message before start date
+ future_course = self.create_future_course()
+ CourseEnrollment.enroll(self.user, future_course.id)
+ url = course_home_url(future_course)
+ response = self.client.get(url)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
+ self.assertContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START)
diff --git a/openedx/features/course_experience/tests/views/test_course_updates.py b/openedx/features/course_experience/tests/views/test_course_updates.py
index ad2d481381..49c979632a 100644
--- a/openedx/features/course_experience/tests/views/test_course_updates.py
+++ b/openedx/features/course_experience/tests/views/test_course_updates.py
@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed
- with self.assertNumQueries(31, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
+ with self.assertNumQueries(30, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_updates_url(self.course)
self.client.get(url)
diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py
index b1cd16b562..017dcac4cf 100644
--- a/openedx/features/course_experience/views/course_home.py
+++ b/openedx/features/course_experience/views/course_home.py
@@ -26,6 +26,7 @@ from web_fragments.fragment import Fragment
from ..utils import get_course_outline_block_tree
from .course_dates import CourseDatesFragmentView
+from .course_home_messages import CourseHomeMessageFragmentView
from .course_outline import CourseOutlineFragmentView
from .course_sock import CourseSockFragmentView
from .welcome_message import WelcomeMessageFragmentView
@@ -113,9 +114,12 @@ class CourseHomeFragmentView(EdxFragmentView):
# Render the full content to enrolled users, as well as to course and global staff.
# Unenrolled users who are not course or global staff are given only a subset.
- is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key)
- is_staff = has_access(request.user, 'staff', course_key)
- if is_enrolled or is_staff:
+ user_access = {
+ 'is_anonymous': request.user.is_anonymous(),
+ 'is_enrolled': CourseEnrollment.is_enrolled(request.user, course_key),
+ 'is_staff': has_access(request.user, 'staff', course_key),
+ }
+ if user_access['is_enrolled'] or user_access['is_staff']:
outline_fragment = CourseOutlineFragmentView().render_to_fragment(request, course_id=course_id, **kwargs)
welcome_message_fragment = WelcomeMessageFragmentView().render_to_fragment(
request, course_id=course_id, **kwargs
@@ -141,6 +145,11 @@ class CourseHomeFragmentView(EdxFragmentView):
# Get the course tools enabled for this user and course
course_tools = CourseToolsPluginManager.get_enabled_course_tools(request, course_key)
+ # Grab the course home messages fragment to render any relevant django messages
+ course_home_message_fragment = CourseHomeMessageFragmentView().render_to_fragment(
+ request, course_id=course_id, user_access=user_access, **kwargs
+ )
+
# Render the course home fragment
context = {
'request': request,
@@ -149,6 +158,7 @@ class CourseHomeFragmentView(EdxFragmentView):
'course_key': course_key,
'outline_fragment': outline_fragment,
'handouts_html': handouts_html,
+ 'course_home_message_fragment': course_home_message_fragment,
'has_visited_course': has_visited_course,
'resume_course_url': resume_course_url,
'course_tools': course_tools,
diff --git a/openedx/features/course_experience/views/course_home_messages.py b/openedx/features/course_experience/views/course_home_messages.py
new file mode 100644
index 0000000000..d14280079d
--- /dev/null
+++ b/openedx/features/course_experience/views/course_home_messages.py
@@ -0,0 +1,126 @@
+"""
+View logic for handling course messages.
+"""
+
+from babel.dates import format_date, format_timedelta
+from datetime import datetime
+
+from courseware.courses import get_course_with_access
+from django.template.loader import render_to_string
+from django.utils.http import urlquote_plus
+from django.utils.timezone import UTC
+from django.utils.translation import get_language, to_locale
+from django.utils.translation import ugettext as _
+from openedx.core.djangolib.markup import Text, HTML
+from opaque_keys.edx.keys import CourseKey
+from web_fragments.fragment import Fragment
+
+from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
+from openedx.features.course_experience import CourseHomeMessages
+
+
+class CourseHomeMessageFragmentView(EdxFragmentView):
+ """
+ A fragment that displays a course message with an alert and call
+ to action for three types of users:
+
+ 1) Not logged in users are given a link to sign in or register.
+ 2) Unenrolled users are given a link to enroll.
+ 3) Enrolled users who get to the page before the course start date
+ are given the option to add the start date to their calendar.
+
+ This fragment requires a user_access map as follows:
+
+ user_access = {
+ 'is_anonymous': True if the user is logged in, False otherwise
+ 'is_enrolled': True if the user is enrolled in the course, False otherwise
+ 'is_staff': True if the user is a staff member of the course, False otherwise
+ }
+ """
+ def render_to_fragment(self, request, course_id, user_access, **kwargs):
+ """
+ Renders a course message fragment for the specified course.
+ """
+ course_key = CourseKey.from_string(course_id)
+ course = get_course_with_access(request.user, 'load', course_key)
+
+ # Get time until the start date, if already started, or no start date, value will be zero or negative
+ now = datetime.now(UTC())
+ already_started = course.start and now > course.start
+ days_until_start_string = "started" if already_started else format_timedelta(course.start - now, locale=to_locale(get_language()))
+ course_start_data = {
+ 'course_start_date': format_date(course.start, locale=to_locale(get_language())),
+ 'already_started': already_started,
+ 'days_until_start_string': days_until_start_string
+ }
+
+ # Register the course home messages to be loaded on the page
+ self.register_course_home_messages(request, course, user_access, course_start_data)
+
+ # Grab the relevant messages
+ course_home_messages = list(CourseHomeMessages.user_messages(request))
+
+ # Return None if user is enrolled and course has begun
+ if user_access['is_enrolled'] and already_started:
+ return None
+
+ # Grab the logo
+ image_src = "course_experience/images/home_message_author.png"
+
+ context = {
+ 'course_home_messages': course_home_messages,
+ 'image_src': image_src,
+ }
+
+ html = render_to_string('course_experience/course-messages-fragment.html', context)
+ return Fragment(html)
+
+ @staticmethod
+ def register_course_home_messages(request, course, user_access, course_start_data):
+ """
+ Register messages to be shown in the course home content page.
+ """
+ if user_access['is_anonymous']:
+ CourseHomeMessages.register_info_message(
+ request,
+ Text(_(
+ " {sign_in_link} or {register_link} and then enroll in this course."
+ )).format(
+ sign_in_link=HTML("{sign_in_label} ").format(
+ sign_in_label=_("Sign in"),
+ current_url=urlquote_plus(request.path),
+ ),
+ register_link=HTML("{register_label} ").format(
+ register_label=_("register"),
+ current_url=urlquote_plus(request.path),
+ )
+ ),
+ title='You must be enrolled in the course to see course content.'
+ )
+ if not user_access['is_anonymous'] and not user_access['is_staff'] and not user_access['is_enrolled']:
+ CourseHomeMessages.register_info_message(
+ request,
+ Text(_(
+ "{open_enroll_link} Enroll now{close_enroll_link} to access the full course."
+ )).format(
+ open_enroll_link='',
+ close_enroll_link=''
+ ),
+ title=Text('Welcome to {course_display_name}').format(
+ course_display_name=course.display_name
+ )
+ )
+ if user_access['is_enrolled'] and not course_start_data['already_started']:
+ CourseHomeMessages.register_info_message(
+ request,
+ Text(_(
+ "{add_reminder_open_tag}Don't forget to add a calendar reminder!{add_reminder_close_tag}."
+ )).format(
+ add_reminder_open_tag='',
+ add_reminder_close_tag=''
+ ),
+ title=Text("Course starts in {days_until_start_string} on {course_start_date}.").format(
+ days_until_start_string=course_start_data['days_until_start_string'],
+ course_start_date=course_start_data['course_start_date']
+ )
+ )
diff --git a/openedx/features/enterprise_support/api.py b/openedx/features/enterprise_support/api.py
index a3bffe22ec..293ab6294d 100644
--- a/openedx/features/enterprise_support/api.py
+++ b/openedx/features/enterprise_support/api.py
@@ -26,7 +26,6 @@ from openedx.core.lib.token_utils import JwtBuilder
try:
from enterprise import utils as enterprise_utils
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomer
- from enterprise.tpa_pipeline import get_enterprise_customer_for_request
from enterprise.utils import consent_necessary_for_course
except ImportError:
pass
@@ -240,9 +239,9 @@ def enterprise_customer_for_request(request, tpa_hint=None):
if not enterprise_enabled():
return None
- ec = get_enterprise_customer_for_request(request)
+ ec = None
- if not ec and tpa_hint:
+ if tpa_hint:
try:
ec = EnterpriseCustomer.objects.get(enterprise_customer_identity_provider__provider_id=tpa_hint)
except EnterpriseCustomer.DoesNotExist:
@@ -308,24 +307,6 @@ def get_enterprise_consent_url(request, course_id, user=None, return_to=None):
return full_url
-def insert_enterprise_pipeline_elements(pipeline):
- """
- If the enterprise app is enabled, insert additional elements into the
- pipeline so that data sharing consent views are used.
- """
- if not enterprise_enabled():
- return
-
- additional_elements = (
- 'enterprise.tpa_pipeline.handle_enterprise_logistration',
- )
- # Find the item we need to insert the data sharing consent elements before
- insert_point = pipeline.index('social_core.pipeline.social_auth.load_extra_data')
-
- for index, element in enumerate(additional_elements):
- pipeline.insert(insert_point + index, element)
-
-
def get_cache_key(**kwargs):
"""
Get MD5 encoded cache key for given arguments.
diff --git a/openedx/features/enterprise_support/tests/test_api.py b/openedx/features/enterprise_support/tests/test_api.py
index 052f7f434c..fbe49f2231 100644
--- a/openedx/features/enterprise_support/tests/test_api.py
+++ b/openedx/features/enterprise_support/tests/test_api.py
@@ -14,7 +14,6 @@ from openedx.features.enterprise_support.api import (
enterprise_enabled,
get_dashboard_consent_notification,
get_enterprise_consent_url,
- insert_enterprise_pipeline_elements
)
@@ -24,33 +23,9 @@ class TestEnterpriseApi(unittest.TestCase):
Test enterprise support APIs.
"""
- @override_settings(ENABLE_ENTERPRISE_INTEGRATION=False)
- def test_utils_with_enterprise_disabled(self):
- """
- Test that disabling the enterprise integration flag causes
- the utilities to return the expected default values.
- """
- self.assertFalse(enterprise_enabled())
- self.assertEqual(insert_enterprise_pipeline_elements(None), None)
-
@override_settings(ENABLE_ENTERPRISE_INTEGRATION=True)
- def test_utils_with_enterprise_enabled(self):
- """
- Test that enabling enterprise integration (which is currently on by default) causes the
- the utilities to return the expected values.
- """
- self.assertTrue(enterprise_enabled())
- pipeline = ['abc', 'social_core.pipeline.social_auth.load_extra_data', 'def']
- insert_enterprise_pipeline_elements(pipeline)
- self.assertEqual(pipeline, ['abc',
- 'enterprise.tpa_pipeline.handle_enterprise_logistration',
- 'social_core.pipeline.social_auth.load_extra_data',
- 'def'])
-
- @override_settings(ENABLE_ENTERPRISE_INTEGRATION=True)
- @mock.patch('openedx.features.enterprise_support.api.get_enterprise_customer_for_request')
@mock.patch('openedx.features.enterprise_support.api.EnterpriseCustomer')
- def test_enterprise_customer_for_request(self, ec_class_mock, get_ec_pipeline_mock):
+ def test_enterprise_customer_for_request(self, ec_class_mock):
"""
Test that the correct EnterpriseCustomer, if any, is returned.
"""
@@ -68,8 +43,6 @@ class TestEnterpriseApi(unittest.TestCase):
ec_class_mock.DoesNotExist = Exception
ec_class_mock.objects.get.side_effect = get_ec_mock
- get_ec_pipeline_mock.return_value = None
-
request = mock.MagicMock()
request.GET.get.return_value = 'real-uuid'
self.assertEqual(enterprise_customer_for_request(request), 'this-is-actually-an-enterprise-customer')
@@ -85,10 +58,6 @@ class TestEnterpriseApi(unittest.TestCase):
self.assertEqual(enterprise_customer_for_request(request, tpa_hint='fake-provider-id'), None)
self.assertEqual(enterprise_customer_for_request(request, tpa_hint=None), None)
- get_ec_pipeline_mock.return_value = 'also-a-real-enterprise'
-
- self.assertEqual(enterprise_customer_for_request(request), 'also-a-real-enterprise')
-
def check_data_sharing_consent(self, consent_required=False, consent_url=None):
"""
Used to test the data_sharing_consent_required view decorator.
diff --git a/package.json b/package.json
index 7fe27ecf41..2b2c6fb228 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"backbone.paginator": "~2.0.3",
"coffee-loader": "^0.7.3",
"coffee-script": "1.6.1",
- "edx-bootstrap": "~0.1.6",
+ "edx-bootstrap": "~0.1.7",
"edx-pattern-library": "0.18.1",
"edx-ui-toolkit": "1.5.2",
"exports-loader": "^0.6.4",
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 1a4e1499c0..c7dbc58b60 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -35,11 +35,9 @@ django-statici18n==1.4.0
django-storages==1.4.1
django-method-override==0.1.0
django-user-tasks==0.1.4
-git+https://github.com/edx/django-rest-framework.git@1ceda7c086fddffd1c440cc86856441bbf0bd9cb#egg=djangorestframework==3.6.3
django==1.8.18
django-waffle==0.12.0
djangorestframework-jwt==1.11.0
-djangorestframework-oauth==1.1.0
enum34==1.1.6
edx-ccx-keys==0.2.1
edx-celeryutils==0.2.4
@@ -51,13 +49,14 @@ edx-lint==0.4.3
astroid==1.3.8
edx-django-oauth2-provider==1.1.4
edx-django-sites-extensions==2.3.0
-edx-enterprise==0.38.5
+edx-enterprise==0.38.6
edx-oauth2-provider==1.2.0
edx-opaque-keys==0.4.0
edx-organizations==0.4.5
edx-rest-api-client==1.7.1
edx-search==1.1.0
-edx-submissions==2.0.4
+edx-submissions==2.0.5
+event-tracking==0.2.4
facebook-sdk==0.4.0
feedparser==5.1.3
firebase-token-generator==1.3.2
diff --git a/requirements/edx/edx-private.txt b/requirements/edx/edx-private.txt
index e6903ec81d..272479d471 100644
--- a/requirements/edx/edx-private.txt
+++ b/requirements/edx/edx-private.txt
@@ -30,7 +30,7 @@ git+https://github.com/open-craft/problem-builder.git@v2.6.5#egg=xblock-problem-
-e git+https://github.com/edx/AnimationXBlock.git@d2b551bb8f49a138088e10298576102164145b87#egg=animation-xblock
# Peer instruction XBlock
-ubcpi-xblock==0.6.2
+ubcpi-xblock==0.6.3
# Vector Drawing and ActiveTable XBlocks (Davidson)
-e git+https://github.com/open-craft/xblock-vectordraw.git@v0.2.1#egg=xblock-vectordraw==0.2.1
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index 5a5f09d934..f3b616531c 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -69,12 +69,18 @@ git+https://github.com/edx/rfc6266.git@v0.0.5-edx#egg=rfc6266==0.0.5-edx
# Used for testing
git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002
+# Why a DRF fork? See: https://openedx.atlassian.net/browse/PLAT-1581
+git+https://github.com/edx/django-rest-framework.git@1ceda7c086fddffd1c440cc86856441bbf0bd9cb#egg=djangorestframework==3.6.3
+
+# Why a drf-oauth fork? To add Django 1.11 compatibility to the abandoned repo.
+# This dependency will be removed by this work: https://openedx.atlassian.net/browse/PLAT-1660
+git+https://github.com/edx/django-rest-framework-oauth.git@0a43e8525f1e3048efe4bc70c03de308a277197c#egg=djangorestframework-oauth==1.1.1
+
# Our libraries:
-e git+https://github.com/edx/codejail.git@a320d43ce6b9c93b17636b2491f724d9e433be47#egg=codejail==0.0
--e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1
-e git+https://github.com/edx/django-splash.git@v0.2#egg=django-splash==0.2
-e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock
-git+https://github.com/edx/edx-ora2.git@1.4.7#egg=ora2==1.4.7
+git+https://github.com/edx/edx-ora2.git@1.4.8#egg=ora2==1.4.8
git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3
git+https://github.com/edx/edx-val.git@0.0.18#egg=edxval==0.0.18
git+https://github.com/edx/RecommenderXBlock.git@0e744b393cf1f8b886fe77bc697e7d9d78d65cd6#egg=recommender-xblock==1.2
diff --git a/themes/edge.edx.org/cms/static/sass/partials/bootstrap/_theme.scss b/themes/edge.edx.org/cms/static/sass/partials/bootstrap/_theme.scss
new file mode 100644
index 0000000000..a6fe8cb699
--- /dev/null
+++ b/themes/edge.edx.org/cms/static/sass/partials/bootstrap/_theme.scss
@@ -0,0 +1,5 @@
+// Override theming for edx.org bootstrap
+
+$body-bg: #f5f5f5 !default;
+
+@import 'edx-bootstrap/sass/edx/theme';
diff --git a/themes/edx.org/cms/static/sass/partials/bootstrap/_theme.scss b/themes/edx.org/cms/static/sass/partials/bootstrap/_theme.scss
new file mode 100644
index 0000000000..a6fe8cb699
--- /dev/null
+++ b/themes/edx.org/cms/static/sass/partials/bootstrap/_theme.scss
@@ -0,0 +1,5 @@
+// Override theming for edx.org bootstrap
+
+$body-bg: #f5f5f5 !default;
+
+@import 'edx-bootstrap/sass/edx/theme';
diff --git a/themes/edx.org/lms/templates/course_modes/choose.html b/themes/edx.org/lms/templates/course_modes/choose.html
index dd57a3f62e..2dc5eee275 100644
--- a/themes/edx.org/lms/templates/course_modes/choose.html
+++ b/themes/edx.org/lms/templates/course_modes/choose.html
@@ -47,6 +47,11 @@ from openedx.core.djangolib.markup import HTML, Text
window.location.href = '${ecommerce_payment_page | n, js_escaped_string}?sku=' +
encodeURIComponent('${sku | n, js_escaped_string}');
});
+ $('.v2 button[name=verified_mode]').click(function(e){
+ e.preventDefault();
+ window.location.href = 'https://ecommerce.edx.org/coupons/redeem/?code=EDXTSV35&sku=' +
+ encodeURIComponent('${sku | n, js_escaped_string}');
+ });
% endif
});
@@ -75,7 +80,7 @@ from openedx.core.djangolib.markup import HTML, Text
${title_content}
-
+
Next, Select Your Learning Path
@@ -86,7 +91,7 @@ from openedx.core.djangolib.markup import HTML, Text
b_tag_kwargs = {'b_start': HTML(''), 'b_end': HTML(' ')}
%>
% if "verified" in modes:
-
+
Pursue a Verified Certificate
@@ -102,7 +107,14 @@ from openedx.core.djangolib.markup import HTML, Text
-
+
+
+
+
+
Upgrade to a Certificate ($${min_price} USD)
+
+
Save 5% if you upgrade now! ($${int(min_price * .95)} USD)
+
@@ -192,41 +204,41 @@ from openedx.core.djangolib.markup import HTML, Text
${_("or")}
-
+
${_("or")}
-
-
+
+
- Donate to Support our Non-Profit Mission
+ I Don't Want to Upgrade or Donate Today
- Any amount will support our mission to make the world's best education more accessible.
+ If you do not want to add a certificate or donate to edX's mission today, you can skip this step for now and continue to the course.
-
-
-
I Don't Want to Upgrade or Donate Today
+
+
+
Donate to Support our Non-Profit Mission
- If you do not want to buy a certificate or donate to edX's mission today, you can skip this step for now and continue to the course.
+ Even if you are not interested in pursuing a Verified Certificate, a donation helps edX continue to work towards its non-profit mission of making the world's best education more accessible to learners everywhere.
diff --git a/themes/edx.org/openedx/features/course_experience/static/course_experience/images/home_message_author.png b/themes/edx.org/openedx/features/course_experience/static/course_experience/images/home_message_author.png
new file mode 100644
index 0000000000..5561483d54
Binary files /dev/null and b/themes/edx.org/openedx/features/course_experience/static/course_experience/images/home_message_author.png differ
diff --git a/themes/red-theme/cms/static/images/favicon.ico b/themes/red-theme/cms/static/images/favicon.ico
new file mode 100644
index 0000000000..5e784977e6
Binary files /dev/null and b/themes/red-theme/cms/static/images/favicon.ico differ
diff --git a/themes/red-theme/cms/static/images/studio-logo.png b/themes/red-theme/cms/static/images/studio-logo.png
new file mode 100644
index 0000000000..85ea048de2
Binary files /dev/null and b/themes/red-theme/cms/static/images/studio-logo.png differ
diff --git a/themes/red-theme/lms/static/sass/partials/base/_variables.scss b/themes/red-theme/cms/static/sass/partials/base/_variables.scss
similarity index 100%
rename from themes/red-theme/lms/static/sass/partials/base/_variables.scss
rename to themes/red-theme/cms/static/sass/partials/base/_variables.scss
diff --git a/themes/red-theme/cms/static/sass/partials/bootstrap/_theme.scss b/themes/red-theme/cms/static/sass/partials/bootstrap/_theme.scss
new file mode 100644
index 0000000000..7fb5406954
--- /dev/null
+++ b/themes/red-theme/cms/static/sass/partials/bootstrap/_theme.scss
@@ -0,0 +1,13 @@
+// Sample red theme to demonstrate SASS overrides
+
+// Theme colors
+//
+// Note: define colors needed by your theme first
+$red: #d9534f !default;
+$brand-primary: $red;
+
+// Theme fonts
+$font-family-sans-serif: cursive;
+
+// Initialize the Open edX bootstrap theme
+@import 'edx-bootstrap/sass/open-edx/theme';
diff --git a/themes/red-theme/cms/static/sass/partials/cms/base/_variables.scss b/themes/red-theme/cms/static/sass/partials/cms/base/_variables.scss
new file mode 100755
index 0000000000..9a779f5e13
--- /dev/null
+++ b/themes/red-theme/cms/static/sass/partials/cms/base/_variables.scss
@@ -0,0 +1,14 @@
+// Color overrides
+$white: rgb(255,255,255);
+$red: #d9534f !default;
+
+$footer-bg: $white;
+$header-bg: $white;
+$header-border-color: $red;
+
+$base-font-color: $red;
+$link-color: $red;
+$lms-active-color: $red;
+$lms-label-color: $red;
+
+@import 'cms/static/sass/partials/cms/base/variables';