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/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/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/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 35964b9524..23ccb09113 100644
--- a/lms/djangoapps/django_comment_client/base/tests.py
+++ b/lms/djangoapps/django_comment_client/base/tests.py
@@ -405,7 +405,7 @@ class ViewsQueryCountTestCase(
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 4, 31),
- (ModuleStoreEnum.Type.split, 3, 12, 31),
+ (ModuleStoreEnum.Type.split, 3, 13, 31),
)
@ddt.unpack
@count_queries
@@ -414,7 +414,7 @@ class ViewsQueryCountTestCase(
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 3, 27),
- (ModuleStoreEnum.Type.split, 3, 9, 27),
+ (ModuleStoreEnum.Type.split, 3, 10, 27),
)
@ddt.unpack
@count_queries
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 cb1e952aea..d6aa483b71 100644
--- a/lms/static/sass/_experiments.scss
+++ b/lms/static/sass/_experiments.scss
@@ -14,17 +14,17 @@
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;
@@ -38,13 +38,7 @@
font-weight: 500 !important;
}
-@media (min-width: 375px) {
- .donation-link {
- font-size: 16px;
- }
-}
-
-.v2.register-choice-v2-audit {
+.v2.register-choice-v2-donate {
height: 300px;
background: none !important;
border-top-color: grey !important;
@@ -52,16 +46,16 @@
}
@media screen and (min-width: 375px) {
- .v2.register-choice-v2-audit {
+ .v2.register-choice-v2-donate {
height: 250px;
}
}
-.v2.register-choice-v2-audit .list-actions {
+.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;
@@ -70,13 +64,13 @@
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;
}
@@ -90,17 +84,17 @@
margin-left: 5px;
}
-.v2 .donation-link {
+.v2 .continue-link {
font-weight: bold !important;
}
.v2.register-choice-certificate,
-.v2.register-choice-donate,
+.v2.register-choice-continue,
.v2.register-choice-view {
width: 100%;
}
-.v2.register-choice-donate {
+.v2.register-choice-continue {
border-color: #D7548E !important;
}
@@ -108,15 +102,15 @@
max-height: 115px;
}
-.v2.register-choice-v2-audit .wrapper-copy-inline {
+.v2.register-choice-v2-donate .wrapper-copy-inline {
display: block !important;
}
-.v2.register-choice-v2-audit .copy-inline {
+.v2.register-choice-v2-donate .copy-inline {
width: 100% !important;
}
-.v2.register-choice-v2-audit .list-actions {
+.v2.register-choice-v2-donate .list-actions {
width: 100% !important;
margin-top: 20px !important;
text-align: center !important;
@@ -126,7 +120,7 @@
width: 100% !important;
}
-.v2 input{
+.v2 input, .v2 a {
font-size: 15px !important;
}
@@ -164,7 +158,7 @@
}
}
-.v2 .donation-link, .v2 input, .v2 button {
+.v2 .continue-link, .v2 input, .v2 button, .v2 a {
width: 100%;
}
@@ -189,21 +183,22 @@
@media (min-width: 768px) {
.v2.register-choice-certificate,
- .v2.register-choice-donate {
+ .v2.register-choice-continue,
+ .v2.deco-divider {
width: 46.5% !important;
display: inline-block;
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;
}
@@ -212,16 +207,16 @@
width: 100% !important;
}
- .v2 input {
+ .v2 input, .v2 a {
font-size: 15px !important;
}
- .v2 .donation-link, .v2.register-choice-certificate button {
+ .v2 .continue-link, .v2.register-choice-certificate button, .v2.register-choice-certificate input {
margin-top: 20px;
width: initial;
}
- .v2.register-choice-v2-audit input {
+ .v2.register-choice-v2-donate a {
width: 100% !important;
}
@@ -237,7 +232,7 @@
margin: 0 2% 20px 0;
}
- .v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
+ .v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
width: 60%;
}
@@ -249,7 +244,7 @@
padding: 15px !important;
}
- .v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
+ .v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
width: 60%;
}
@@ -271,14 +266,14 @@
width: 100% !important;
}
- .v2 .donation-link:hover,
- .v2 .donation-link:focus {
+ .v2 .continue-link:hover,
+ .v2 .continue-link:focus {
background-color: #D7548E !important;
color: white !important;
text-decoration: none;
}
- .v2 .donation-link:hover {
+ .v2 .continue-link:hover {
cursor: pointer;
}
@@ -366,13 +361,14 @@
@media (min-width: 835px) {
.v2.register-choice-certificate,
- .v2.register-choice-donate {
+ .v2.register-choice-continue,
+ .v2.deco-divider {
min-height: 250px;
}
}
@media (min-width: 1024px) {
- .v2 .donation-link {
+ .v2 .continue-link {
width: 55%;
}
.v2.deco-divider .copy {
@@ -380,15 +376,18 @@
}
}
-@media (min-width: 1064px) {
+@media (min-width: 1096px) {
.v2.register-choice-certificate,
- .v2.register-choice-donate {
+ .v2.register-choice-continue,
+ .v2.deco-divider {
min-height: 260px;
}
.v2 .img-certificate, .v2 .img-donate {
+ margin-top: 10px;
display: initial;
}
- .v2 .donation-link, .v2.register-choice-certificate button {
+ .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/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/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 ac982aff74..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):
@@ -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/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/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 6eef908b54..c7dbc58b60 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -56,6 +56,7 @@ edx-organizations==0.4.5
edx-rest-api-client==1.7.1
edx-search==1.1.0
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/github.txt b/requirements/edx/github.txt
index a193bc787f..661042b5f6 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -78,7 +78,6 @@ git+https://github.com/edx/django-rest-framework-oauth.git@0a43e8525f1e3048efe4b
# 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.8#egg=ora2==1.4.8
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 a8ab708436..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
});
@@ -205,7 +210,7 @@ from openedx.core.djangolib.markup import HTML, Text
-
+
I Don't Want to Upgrade or Donate Today
@@ -218,14 +223,14 @@ from openedx.core.djangolib.markup import HTML, Text
-
+
Donate to Support our Non-Profit Mission
@@ -233,7 +238,7 @@ from openedx.core.djangolib.markup import HTML, Text
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';