Commit 39a27ac7 by Tyler Hallada

Merge remote-tracking branch 'origin/master' into EDUCATOR-926

parents e6194065 f625e032
...@@ -131,3 +131,6 @@ lms/lib/comment_client/python ...@@ -131,3 +131,6 @@ lms/lib/comment_client/python
autodeploy.properties autodeploy.properties
.ws_migrations_complete .ws_migrations_complete
dist dist
# Visual Studio Code
.vscode
...@@ -318,7 +318,7 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin): ...@@ -318,7 +318,7 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin):
) )
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.split, 3, 13), (ModuleStoreEnum.Type.split, 4, 23),
(ModuleStoreEnum.Type.mongo, USER_COURSES_COUNT, 2) (ModuleStoreEnum.Type.mongo, USER_COURSES_COUNT, 2)
) )
@ddt.unpack @ddt.unpack
......
...@@ -1547,22 +1547,27 @@ def group_configurations_list_handler(request, course_key_string): ...@@ -1547,22 +1547,27 @@ def group_configurations_list_handler(request, course_key_string):
all_partitions = GroupConfiguration.get_all_user_partition_details(store, course) all_partitions = GroupConfiguration.get_all_user_partition_details(store, course)
should_show_enrollment_track = False should_show_enrollment_track = False
group_schemes = [] has_content_groups = False
displayable_partitions = []
for partition in all_partitions: for partition in all_partitions:
group_schemes.append(partition['scheme']) if partition['scheme'] == COHORT_SCHEME:
if partition['scheme'] == ENROLLMENT_SCHEME: has_content_groups = True
enrollment_track_configuration = partition displayable_partitions.append(partition)
should_show_enrollment_track = len(enrollment_track_configuration['groups']) > 1 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. # Add it to the front of the list if it should be shown.
all_partitions.remove(partition)
if should_show_enrollment_track: 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. # 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. # This will add ability to add new groups in the view.
if COHORT_SCHEME not in group_schemes: if not has_content_groups:
all_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course))
return render_to_response('group_configurations.html', { return render_to_response('group_configurations.html', {
'context_course': course, 'context_course': course,
...@@ -1570,7 +1575,7 @@ def group_configurations_list_handler(request, course_key_string): ...@@ -1570,7 +1575,7 @@ def group_configurations_list_handler(request, course_key_string):
'course_outline_url': course_outline_url, 'course_outline_url': course_outline_url,
'experiment_group_configurations': experiment_group_configurations, 'experiment_group_configurations': experiment_group_configurations,
'should_show_experiment_groups': should_show_experiment_groups, '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 'should_show_enrollment_track': should_show_enrollment_track
}) })
elif "application/json" in request.META.get('HTTP_ACCEPT'): elif "application/json" in request.META.get('HTTP_ACCEPT'):
......
...@@ -250,6 +250,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations ...@@ -250,6 +250,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
Basic check that the groups configuration page responds correctly. Basic check that the groups configuration page responds correctly.
""" """
# This creates a random UserPartition.
self.course.user_partitions = [ self.course.user_partitions = [
UserPartition(0, 'First name', 'First description', [Group(0, 'Group A'), Group(1, 'Group B'), Group(2, 'Group C')]), 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 ...@@ -261,7 +262,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
response = self.client.get(self._url()) response = self.client.get(self._url())
self.assertEqual(response.status_code, 200) 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, 'Group C')
self.assertContains(response, CONTENT_GROUP_CONFIGURATION_NAME) self.assertContains(response, CONTENT_GROUP_CONFIGURATION_NAME)
......
{ {
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "", "AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "", "AWS_SECRET_ACCESS_KEY": "",
"CELERY_BROKER_PASSWORD": "celery", "CELERY_BROKER_PASSWORD": "celery",
......
{ {
"ANALYTICS_SERVER_URL": "",
"BOOK_URL": "", "BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com", "BUGS_EMAIL": "bugs@example.com",
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com", "BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",
......
{ {
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "", "AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "", "AWS_SECRET_ACCESS_KEY": "",
"CELERY_BROKER_PASSWORD": "celery", "CELERY_BROKER_PASSWORD": "celery",
......
{ {
"ANALYTICS_SERVER_URL": "",
"BOOK_URL": "", "BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com", "BUGS_EMAIL": "bugs@example.com",
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com", "BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",
......
...@@ -710,6 +710,10 @@ base_vendor_js = [ ...@@ -710,6 +710,10 @@ base_vendor_js = [
'edx-ui-toolkit/js/utils/string-utils.js', 'edx-ui-toolkit/js/utils/string-utils.js',
'edx-ui-toolkit/js/utils/html-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 # Finally load RequireJS
'common/js/vendor/require.js' 'common/js/vendor/require.js'
] ]
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
// +Base - Utilities // +Base - Utilities
// ==================== // ====================
@import 'partials/variables'; @import 'cms/base/variables';
@import 'mixins'; @import 'mixins';
@import 'mixins-inherited'; @import 'mixins-inherited';
......
...@@ -6,10 +6,13 @@ ...@@ -6,10 +6,13 @@
// Configuration // Configuration
@import 'config'; @import 'config';
// Extensions // +Base - Utilities
@import 'partials/variables'; // ====================
@import 'partials/cms/base/variables';
@import 'mixins-v2'; @import 'mixins-v2';
@import 'base-v2'; @import 'base-v2';
// Pattern Library styling
@import 'elements-v2/controls'; @import 'elements-v2/controls';
@import 'elements-v2/header'; @import 'elements-v2/header';
@import 'elements-v2/navigation'; @import 'elements-v2/navigation';
......
// 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;
}
// 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;
}
}
}
// 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;
}
}
}
}
}
// 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;
}
// 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;
}
}
// 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;
}
}
}
}
}
// 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;
// -----------------------------
// 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';
// Default bootstrap theming
$body-bg: #f5f5f5 !default;
@import 'edx-bootstrap/sass/open-edx/theme';
...@@ -44,7 +44,11 @@ from openedx.core.djangolib.js_utils import ( ...@@ -44,7 +44,11 @@ from openedx.core.djangolib.js_utils import (
<%static:css group='style-vendor-tinymce-content'/> <%static:css group='style-vendor-tinymce-content'/>
<%static:css group='style-vendor-tinymce-skin'/> <%static:css group='style-vendor-tinymce-skin'/>
<%static:css group='${self.attr.main_css}'/> % if uses_bootstrap:
<link rel="stylesheet" href="${static.url(self.attr.main_css)}" type="text/css" media="all" />
% else:
<%static:css group='${self.attr.main_css}'/>
% endif
<%include file="widgets/segment-io.html" /> <%include file="widgets/segment-io.html" />
......
<!-- NOTE:
This file is a static reference template used by the edX design and development teams while
building new features. These files are not generally maintained or updated once a feature has
been completed. Additionally, these templates are subject to the following:
* inconsistent markup/UI with current UI
* deletion by the edX design or development if not needed
* not compliant with internationalization, javascript, or accessibility standards used
throughout the rest of the platform
-->
## Override the default styles_version to the Pattern Library version (version 2) ## Override the default styles_version to use Bootstrap
<%! main_css = "style-main-v2" %> <%! main_css = "css/bootstrap/studio-main.css" %>
<%page expression_filter="h"/> <%page expression_filter="h"/>
<%inherit file="../../base.html" />
<%block name="view_notes"> <%inherit file="../../base.html" />
<%include file="_note-usage.html" />
</%block>
<%block name="title">UX Style Reference</%block> <%block name="title">UX Style Reference</%block>
<%block name="bodyclass">is-signedin course uploads view-container</%block> <%block name="bodyclass">ux-reference</%block>
<%block name="content"> <%block name="content">
<div class="main-wrapper"> <div class="main-wrapper">
<div class="inner-wrapper"> <div class="inner-wrapper">
<div class="main-column"> <div class="main-column">
<article class="window unit-body"> <article class="window unit-body">
<h1>UX Style Reference</h1> <h2>UX Style Reference</h2>
<ol class="components"> <ul>
<li class="component"> <a href="bootstrap/test.html">Bootstrap Test Page</a>
<div class="wrapper wrapper-component-action-header"> <a href="pattern-library/test.html">Pattern Library Test Page</a>
<h2>Page Types</h2> </ul>
</div>
<section class="xblock xblock-student_view xmodule_display xmodule_HtmlModule">
<ul>
<li><a href="pattern-library-test.html">Pattern Library test page</a></li>
</ul>
</section>
</li>
</ol>
</article> </article>
</div> </div>
</div> </div>
......
...@@ -93,6 +93,7 @@ source, template_path = Loader(engine).load_template_source(path) ...@@ -93,6 +93,7 @@ source, template_path = Loader(engine).load_template_source(path)
</%doc> </%doc>
<% <%
from django.template import Template, Context from django.template import Template, Context
from webpack_loader.exceptions import WebpackLoaderBadStatsError
try: try:
return Template(""" return Template("""
{% load render_bundle from webpack_loader %} {% load render_bundle from webpack_loader %}
...@@ -105,9 +106,9 @@ source, template_path = Loader(engine).load_template_source(path) ...@@ -105,9 +106,9 @@ source, template_path = Loader(engine).load_template_source(path)
'entry': entry, 'entry': entry,
'body': capture(caller.body) 'body': capture(caller.body)
})) }))
except IOError as e: except (IOError, WebpackLoaderBadStatsError) as e:
# Don't break Mako template rendering if the bundle or webpack-stats can't be found, but log it # Don't break Mako template rendering if the bundle or webpack-stats can't be found, but log it
logger.error(e) logger.error('[LEARNER-1938] {error}'.format(error=e))
%> %>
</%def> </%def>
......
...@@ -26,10 +26,10 @@ class GlobalStatusMessage(ConfigurationModel): ...@@ -26,10 +26,10 @@ class GlobalStatusMessage(ConfigurationModel):
msg = self.message msg = self.message
if course_key: if course_key:
try: try:
course_message = self.coursemessage_set.get(course_key=course_key) course_home_message = self.coursemessage_set.get(course_key=course_key)
# Don't add the message if course_message is blank. # Don't add the message if course_home_message is blank.
if course_message: if course_home_message:
msg = u"{} <br /> {}".format(msg, course_message.message) msg = u"{} <br /> {}".format(msg, course_home_message.message)
except CourseMessage.DoesNotExist: except CourseMessage.DoesNotExist:
# We don't have a course-specific message, so pass. # We don't have a course-specific message, so pass.
pass pass
......
...@@ -734,13 +734,12 @@ def dashboard(request): ...@@ -734,13 +734,12 @@ def dashboard(request):
show_courseware_links_for = frozenset( show_courseware_links_for = frozenset(
enrollment.course_id for enrollment in course_enrollments enrollment.course_id for enrollment in course_enrollments
if has_access(request.user, 'load', enrollment.course_overview) 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 # Find programs associated with course runs being displayed. This information
# is passed in the template context to allow rendering of program-related # is passed in the template context to allow rendering of program-related
# information on the dashboard. # information on the dashboard.
meter = ProgramProgressMeter(user, enrollments=course_enrollments) meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
inverted_programs = meter.invert_programs() inverted_programs = meter.invert_programs()
# Construct a dictionary of course mode information # Construct a dictionary of course mode information
......
...@@ -23,32 +23,3 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware): ...@@ -23,32 +23,3 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware):
redirect_uri = pipeline.AUTH_DISPATCH_URLS[auth_entry] redirect_uri = pipeline.AUTH_DISPATCH_URLS[auth_entry]
return redirect_uri return redirect_uri
class PipelineQuarantineMiddleware(object):
"""
Middleware flushes the session if a user agent with a quarantined session
attempts to leave the quarantined set of views.
"""
def process_view(self, request, view_func, view_args, view_kwargs): # pylint: disable=unused-argument
"""
Check the session to see if we've quarantined the user to a particular
step of the authentication pipeline; if so, look up which modules the
user is allowed to browse to without breaking the pipeline. If the view
that's been requested is outside those modules, then flush the session.
In general, this middleware should be used in cases where allowing the
user to exit the running pipeline would be undesirable, and where it'd
be better to flush the session state rather than allow it. Pipeline
quarantining is utilized by the Enterprise application to enforce
collection of user consent for sharing data with a linked third-party
authentication provider.
"""
if not pipeline.running(request):
return
view_module = view_func.__module__
quarantined_modules = request.session.get('third_party_auth_quarantined_modules')
if quarantined_modules is not None and not any(view_module.startswith(mod) for mod in quarantined_modules):
request.session.flush()
...@@ -10,12 +10,9 @@ If true, it: ...@@ -10,12 +10,9 @@ If true, it:
b) calls apply_settings(), passing in the Django settings b) calls apply_settings(), passing in the Django settings
""" """
from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next'] _FIELDS_STORED_IN_SESSION = ['auth_entry', 'next']
_MIDDLEWARE_CLASSES = ( _MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware', 'third_party_auth.middleware.ExceptionMiddleware',
'third_party_auth.middleware.PipelineQuarantineMiddleware',
) )
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard' _SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
...@@ -58,9 +55,6 @@ def apply_settings(django_settings): ...@@ -58,9 +55,6 @@ def apply_settings(django_settings):
'third_party_auth.pipeline.login_analytics', 'third_party_auth.pipeline.login_analytics',
] ]
# Add enterprise pipeline elements if the enterprise app is installed
insert_enterprise_pipeline_elements(django_settings.SOCIAL_AUTH_PIPELINE)
# Required so that we can use unmodified PSA OAuth2 backends: # Required so that we can use unmodified PSA OAuth2 backends:
django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy' django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy'
......
"""
Test the session-flushing middleware
"""
import unittest
from django.conf import settings
from django.test import Client
from social_django.models import Partial
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TestSessionFlushMiddleware(unittest.TestCase):
"""
Ensure that if the pipeline is exited when it's been quarantined,
the entire session is flushed.
"""
def setUp(self):
self.client = Client()
self.fancy_variable = 13025
self.token = 'pipeline_running'
self.tpa_quarantined_modules = ('fake_quarantined_module',)
def tearDown(self):
Partial.objects.all().delete()
def test_session_flush(self):
"""
Test that a quarantined session is flushed when navigating elsewhere
"""
session = self.client.session
session['fancy_variable'] = self.fancy_variable
session['partial_pipeline_token'] = self.token
session['third_party_auth_quarantined_modules'] = self.tpa_quarantined_modules
session.save()
Partial.objects.create(token=session.get('partial_pipeline_token'))
self.client.get('/')
self.assertEqual(self.client.session.get('fancy_variable', None), None)
def test_session_no_running_pipeline(self):
"""
Test that a quarantined session without a running pipeline is not flushed
"""
session = self.client.session
session['fancy_variable'] = self.fancy_variable
session['third_party_auth_quarantined_modules'] = self.tpa_quarantined_modules
session.save()
self.client.get('/')
self.assertEqual(self.client.session.get('fancy_variable', None), self.fancy_variable)
def test_session_no_quarantine(self):
"""
Test that a session with a running pipeline but no quarantine is not flushed
"""
session = self.client.session
session['fancy_variable'] = self.fancy_variable
session['partial_pipeline_token'] = self.token
session.save()
Partial.objects.create(token=session.get('partial_pipeline_token'))
self.client.get('/')
self.assertEqual(self.client.session.get('fancy_variable', None), self.fancy_variable)
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import unittest import unittest
from openedx.features.enterprise_support.api import enterprise_enabled
from third_party_auth import provider, settings from third_party_auth import provider, settings
from third_party_auth.tests import testutil from third_party_auth.tests import testutil
...@@ -56,8 +55,3 @@ class SettingsUnitTest(testutil.TestCase): ...@@ -56,8 +55,3 @@ class SettingsUnitTest(testutil.TestCase):
# bad in prod. # bad in prod.
settings.apply_settings(self.settings) settings.apply_settings(self.settings)
self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS) self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS)
@unittest.skipUnless(enterprise_enabled(), 'enterprise not enabled')
def test_enterprise_elements_inserted(self):
settings.apply_settings(self.settings)
self.assertIn('enterprise.tpa_pipeline.handle_enterprise_logistration', self.settings.SOCIAL_AUTH_PIPELINE)
...@@ -7,7 +7,6 @@ import logging ...@@ -7,7 +7,6 @@ import logging
import re import re
import json import json
import datetime import datetime
import traceback
from pytz import UTC from pytz import UTC
from collections import defaultdict from collections import defaultdict
...@@ -158,19 +157,7 @@ class ActiveBulkThread(threading.local): ...@@ -158,19 +157,7 @@ class ActiveBulkThread(threading.local):
""" """
def __init__(self, bulk_ops_record_type, **kwargs): def __init__(self, bulk_ops_record_type, **kwargs):
super(ActiveBulkThread, self).__init__(**kwargs) super(ActiveBulkThread, self).__init__(**kwargs)
self._records = defaultdict(bulk_ops_record_type) 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
class BulkOperationsMixin(object): class BulkOperationsMixin(object):
......
...@@ -788,7 +788,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ...@@ -788,7 +788,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
if should_cache_items: if should_cache_items:
self.cache_items(runtime, block_keys, course_entry.course_key, depth, lazy) 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): def _get_cache(self, course_version_guid):
""" """
......
...@@ -418,7 +418,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): ...@@ -418,7 +418,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
# wildcard query, 6! load pertinent items for inheritance calls, load parents, course root fetch (why) # wildcard query, 6! load pertinent items for inheritance calls, load parents, course root fetch (why)
# Split: # Split:
# active_versions (with regex), structure, and spurious active_versions refetch # 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 @ddt.unpack
def test_get_items(self, default_ms, max_find, max_send): def test_get_items(self, default_ms, max_find, max_send):
self.initdb(default_ms) self.initdb(default_ms)
...@@ -1043,7 +1043,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): ...@@ -1043,7 +1043,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
# 1) wildcard split search, # 1) wildcard split search,
# 2-4) active_versions, structure, definition (s/b lazy; so, unnecessary) # 2-4) active_versions, structure, definition (s/b lazy; so, unnecessary)
# 5) wildcard draft mongo which has none # 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 @ddt.unpack
def test_get_courses(self, default_ms, max_find, max_send): def test_get_courses(self, default_ms, max_find, max_send):
self.initdb(default_ms) self.initdb(default_ms)
......
...@@ -21,7 +21,11 @@ from django.utils.timezone import UTC ...@@ -21,7 +21,11 @@ from django.utils.timezone import UTC
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from xblock.core import XBlock 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 ( from courseware.access_utils import (
ACCESS_DENIED, ACCESS_DENIED,
ACCESS_GRANTED, ACCESS_GRANTED,
...@@ -309,7 +313,8 @@ def _has_access_course(user, action, courselike): ...@@ -309,7 +313,8 @@ def _has_access_course(user, action, courselike):
""" """
response = ( response = (
_visible_to_nonstaff_users(courselike) and _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 ( return (
...@@ -355,8 +360,6 @@ def _has_access_course(user, action, courselike): ...@@ -355,8 +360,6 @@ def _has_access_course(user, action, courselike):
checkers = { checkers = {
'load': can_load, '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), 'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, courselike),
'enroll': can_enroll, 'enroll': can_enroll,
'see_exists': see_exists, 'see_exists': see_exists,
...@@ -770,7 +773,7 @@ def _has_fulfilled_all_milestones(user, course_id): ...@@ -770,7 +773,7 @@ def _has_fulfilled_all_milestones(user, course_id):
course_id: ID of the course to check course_id: ID of the course to check
user_id: ID of the user 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): def _has_fulfilled_prerequisites(user, course_id):
...@@ -782,7 +785,7 @@ def _has_fulfilled_prerequisites(user, course_id): ...@@ -782,7 +785,7 @@ def _has_fulfilled_prerequisites(user, course_id):
user: user to check user: user to check
course_id: ID of the course 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): def _has_catalog_visibility(course, visibility_type):
......
...@@ -105,7 +105,7 @@ class StartDateError(AccessError): ...@@ -105,7 +105,7 @@ class StartDateError(AccessError):
super(StartDateError, self).__init__(error_code, developer_message, user_message) super(StartDateError, self).__init__(error_code, developer_message, user_message)
class MilestoneError(AccessError): class MilestoneAccessError(AccessError):
""" """
Access denied because the user has unfulfilled milestones Access denied because the user has unfulfilled milestones
""" """
...@@ -113,7 +113,7 @@ class MilestoneError(AccessError): ...@@ -113,7 +113,7 @@ class MilestoneError(AccessError):
error_code = "unfulfilled_milestones" error_code = "unfulfilled_milestones"
developer_message = "User has unfulfilled milestones" developer_message = "User has unfulfilled milestones"
user_message = _("You have 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): class VisibilityError(AccessError):
......
...@@ -9,7 +9,7 @@ from datetime import datetime ...@@ -9,7 +9,7 @@ from datetime import datetime
import branding import branding
import pytz import pytz
from courseware.access import has_access from courseware.access import has_access
from courseware.access_response import StartDateError from courseware.access_response import StartDateError, MilestoneAccessError
from courseware.date_summary import ( from courseware.date_summary import (
CourseEndDate, CourseEndDate,
CourseStartDate, CourseStartDate,
...@@ -32,6 +32,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_ ...@@ -32,6 +32,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
from path import Path as path from path import Path as path
from static_replace import replace_static_urls from static_replace import replace_static_urls
from student.models import CourseEnrollment from student.models import CourseEnrollment
from survey.utils import is_survey_required_and_unanswered
from util.date_utils import strftime_localized from util.date_utils import strftime_localized
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
...@@ -72,7 +73,7 @@ def get_course_by_id(course_key, depth=0): ...@@ -72,7 +73,7 @@ def get_course_by_id(course_key, depth=0):
raise Http404("Course not found: {}.".format(unicode(course_key))) 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, Given a course_key, look up the corresponding course descriptor,
check that the user has the access to perform the specified action 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= ...@@ -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 check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
or has staff access. 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) 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 return course
...@@ -109,12 +115,13 @@ def get_course_overview_with_access(user, action, course_key, check_if_enrolled= ...@@ -109,12 +115,13 @@ def get_course_overview_with_access(user, action, course_key, check_if_enrolled=
return course_overview 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 Check that the user has the access to perform the specified action
on the course (CourseDescriptor|CourseOverview). on the course (CourseDescriptor|CourseOverview).
check_if_enrolled: If true, additionally verifies that the user is enrolled. 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 # Allow staff full access to the course even if not enrolled
if has_access(user, 'staff', course.id): if has_access(user, 'staff', course.id):
...@@ -130,7 +137,13 @@ def check_course_access(course, user, action, check_if_enrolled=False): ...@@ -130,7 +137,13 @@ def check_course_access(course, user, action, check_if_enrolled=False):
raise CourseAccessRedirect('{dashboard_url}?{params}'.format( raise CourseAccessRedirect('{dashboard_url}?{params}'.format(
dashboard_url=reverse('dashboard'), dashboard_url=reverse('dashboard'),
params=params.urlencode() 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 # Deliberately return a non-specific error message to avoid
# leaking info about access control settings # leaking info about access control settings
...@@ -141,6 +154,11 @@ def check_course_access(course, user, action, check_if_enrolled=False): ...@@ -141,6 +154,11 @@ def check_course_access(course, user, action, check_if_enrolled=False):
if not CourseEnrollment.is_enrolled(user, course.id): if not CourseEnrollment.is_enrolled(user, course.id):
raise CourseAccessRedirect(reverse('about_course', args=[unicode(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): def can_self_enroll_in_course(course_key):
""" """
......
...@@ -15,5 +15,13 @@ class Redirect(Exception): ...@@ -15,5 +15,13 @@ class Redirect(Exception):
class CourseAccessRedirect(Redirect): class CourseAccessRedirect(Redirect):
""" """
Redirect raised when user does not have access to a course. 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
...@@ -595,16 +595,16 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes ...@@ -595,16 +595,16 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
# user should not be able to load course even if enrolled # user should not be able to load course even if enrolled
CourseEnrollmentFactory(user=user, course_id=course.id) 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.assertFalse(response)
self.assertIsInstance(response, access_response.MilestoneError) self.assertIsInstance(response, access_response.MilestoneAccessError)
# Staff can always access course # Staff can always access course
staff = StaffFactory.create(course_key=course.id) 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 # User should be able access after completing required course
fulfill_course_milestone(pre_requisite_course.id, user) 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( @ddt.data(
(True, True, True), (True, True, True),
...@@ -615,8 +615,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes ...@@ -615,8 +615,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
""" """
Test course access on mobile for staff and students. Test course access on mobile for staff and students.
""" """
descriptor = Mock(id=self.course.id, user_partitions=[]) descriptor = CourseFactory()
descriptor._class_tags = {}
descriptor.visible_to_staff_only = False descriptor.visible_to_staff_only = False
descriptor.mobile_available = mobile_available descriptor.mobile_available = mobile_available
...@@ -773,7 +772,7 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase): ...@@ -773,7 +772,7 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
PREREQUISITES_TEST_DATA = list(itertools.product( PREREQUISITES_TEST_DATA = list(itertools.product(
['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous'], ['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'], ['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'],
)) ))
......
...@@ -388,7 +388,7 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest ...@@ -388,7 +388,7 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
def test_num_queries_instructor_paced(self): def test_num_queries_instructor_paced(self):
self.fetch_course_info_with_queries(self.instructor_paced_course, 25, 3) self.fetch_course_info_with_queries(self.instructor_paced_course, 24, 3)
def test_num_queries_self_paced(self): def test_num_queries_self_paced(self):
self.fetch_course_info_with_queries(self.self_paced_course, 25, 3) self.fetch_course_info_with_queries(self.self_paced_course, 24, 3)
...@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase): ...@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20 NUM_PROBLEMS = 20
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 143), (ModuleStoreEnum.Type.mongo, 10, 142),
(ModuleStoreEnum.Type.split, 4, 143), (ModuleStoreEnum.Type.split, 4, 142),
) )
@ddt.unpack @ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
...@@ -1464,12 +1464,12 @@ class ProgressPageTests(ProgressPageBaseTests): ...@@ -1464,12 +1464,12 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses.""" """Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save() SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced) self.setup_course(self_paced=self_paced)
with self.assertNumQueries(40, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1): with self.assertNumQueries(39, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
self._get_progress_page() self._get_progress_page()
@ddt.data( @ddt.data(
(False, 40, 26), (False, 39, 25),
(True, 33, 22) (True, 32, 21)
) )
@ddt.unpack @ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent): def test_progress_queries(self, enable_waffle, initial, subsequent):
......
...@@ -52,7 +52,6 @@ from ..model_data import FieldDataCache ...@@ -52,7 +52,6 @@ from ..model_data import FieldDataCache
from ..module_render import get_module_for_descriptor, toc_for_course from ..module_render import get_module_for_descriptor, toc_for_course
from .views import ( from .views import (
CourseTabView, CourseTabView,
check_access_to_course,
check_and_get_upgrade_link, check_and_get_upgrade_link,
get_cosmetic_verified_display_price get_cosmetic_verified_display_price
) )
...@@ -136,7 +135,6 @@ class CoursewareIndex(View): ...@@ -136,7 +135,6 @@ class CoursewareIndex(View):
""" """
Render the index page. Render the index page.
""" """
check_access_to_course(request, self.course)
self._redirect_if_needed_to_pay_for_course() self._redirect_if_needed_to_pay_for_course()
self._prefetch_and_bind_course(request) self._prefetch_and_bind_course(request)
......
...@@ -9,7 +9,6 @@ from datetime import datetime, timedelta ...@@ -9,7 +9,6 @@ from datetime import datetime, timedelta
import analytics import analytics
import shoppingcart import shoppingcart
import survey.utils
import survey.views import survey.views
import waffle import waffle
from certificates import api as certs_api from certificates import api as certs_api
...@@ -82,7 +81,7 @@ from openedx.core.djangoapps.plugin_api.views import EdxFragmentView ...@@ -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.programs.utils import ProgramMarketingDataExtender
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers 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.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 import UNIFIED_COURSE_TAB_FLAG, course_home_url_name
from openedx.features.course_experience.course_tools import CourseToolsPluginManager from openedx.features.course_experience.course_tools import CourseToolsPluginManager
...@@ -91,7 +90,6 @@ from openedx.features.enterprise_support.api import data_sharing_consent_require ...@@ -91,7 +90,6 @@ from openedx.features.enterprise_support.api import data_sharing_consent_require
from rest_framework import status from rest_framework import status
from shoppingcart.utils import is_shopping_cart_enabled from shoppingcart.utils import is_shopping_cart_enabled
from student.models import CourseEnrollment, UserTestGroup from student.models import CourseEnrollment, UserTestGroup
from survey.utils import must_answer_survey
from util.cache import cache, cache_if_anonymous from util.cache import cache, cache_if_anonymous
from util.db import outer_atomic from util.db import outer_atomic
from util.milestones_helpers import get_prerequisite_courses_display from util.milestones_helpers import get_prerequisite_courses_display
...@@ -278,10 +276,6 @@ def course_info(request, course_id): ...@@ -278,10 +276,6 @@ def course_info(request, course_id):
if not user_is_enrolled and not can_self_enroll_in_course(course_key): if not user_is_enrolled and not can_self_enroll_in_course(course_key):
return redirect(reverse('dashboard')) 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) # 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 # 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 # to send them to that specific course module before allowing them into other areas
...@@ -424,9 +418,6 @@ class CourseTabView(EdxFragmentView): ...@@ -424,9 +418,6 @@ class CourseTabView(EdxFragmentView):
with modulestore().bulk_operations(course_key): with modulestore().bulk_operations(course_key):
course = get_course_with_access(request.user, 'load', course_key) course = get_course_with_access(request.user, 'load', course_key)
try: try:
# Verify that the user has access to the course
check_access_to_course(request, course)
# Show warnings if the user has limited access # Show warnings if the user has limited access
self.register_user_access_warning_messages(request, course_key) self.register_user_access_warning_messages(request, course_key)
...@@ -456,7 +447,7 @@ class CourseTabView(EdxFragmentView): ...@@ -456,7 +447,7 @@ class CourseTabView(EdxFragmentView):
is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key) is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key)
is_staff = has_access(request.user, 'staff', course_key) is_staff = has_access(request.user, 'staff', course_key)
if request.user.is_anonymous(): if request.user.is_anonymous():
register_warning_message( PageLevelMessages.register_warning_message(
request, request,
Text(_("To see course content, {sign_in_link} or {register_link}.")).format( Text(_("To see course content, {sign_in_link} or {register_link}.")).format(
sign_in_link=HTML('<a href="/login?next={current_url}">{sign_in_label}</a>').format( sign_in_link=HTML('<a href="/login?next={current_url}">{sign_in_label}</a>').format(
...@@ -470,7 +461,7 @@ class CourseTabView(EdxFragmentView): ...@@ -470,7 +461,7 @@ class CourseTabView(EdxFragmentView):
) )
) )
elif not is_enrolled and not is_staff: elif not is_enrolled and not is_staff:
register_warning_message( PageLevelMessages.register_warning_message(
request, request,
Text(_('You must be enrolled in the course to see course content. {enroll_link}.')).format( Text(_('You must be enrolled in the course to see course content. {enroll_link}.')).format(
enroll_link=HTML('<a href="{url_to_enroll}">{enroll_link_label}</a>').format( enroll_link=HTML('<a href="{url_to_enroll}">{enroll_link_label}</a>').format(
...@@ -739,8 +730,7 @@ def course_about(request, course_id): ...@@ -739,8 +730,7 @@ def course_about(request, course_id):
show_courseware_link = bool( show_courseware_link = bool(
( (
has_access(request.user, 'load', course) and has_access(request.user, 'load', course)
has_access(request.user, 'view_courseware_with_prerequisites', course)
) or settings.FEATURES.get('ENABLE_LMS_MIGRATION') ) or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
) )
...@@ -848,7 +838,7 @@ def program_marketing(request, program_uuid): ...@@ -848,7 +838,7 @@ def program_marketing(request, program_uuid):
""" """
Display the program marketing page. 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: if not program_data:
raise Http404 raise Http404
...@@ -921,9 +911,6 @@ def _progress(request, course_key, student_id): ...@@ -921,9 +911,6 @@ def _progress(request, course_key, student_id):
# NOTE: To make sure impersonation by instructor works, use # NOTE: To make sure impersonation by instructor works, use
# student instead of request.user in the rest of the function. # 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 # The pre-fetching of groups is done to make auth checks not require an
# additional DB lookup (this kills the Progress page in particular). # additional DB lookup (this kills the Progress page in particular).
student = User.objects.prefetch_related("groups").get(id=student.id) student = User.objects.prefetch_related("groups").get(id=student.id)
...@@ -1311,7 +1298,7 @@ def course_survey(request, course_id): ...@@ -1311,7 +1298,7 @@ def course_survey(request, course_id):
""" """
course_key = CourseKey.from_string(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]) redirect_url = reverse(course_home_url_name(course.id), args=[course_id])
...@@ -1721,22 +1708,3 @@ def get_financial_aid_courses(user): ...@@ -1721,22 +1708,3 @@ def get_financial_aid_courses(user):
) )
return financial_aid_courses 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)]))
...@@ -404,8 +404,8 @@ class ViewsQueryCountTestCase( ...@@ -404,8 +404,8 @@ class ViewsQueryCountTestCase(
return inner return inner
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 4, 32), (ModuleStoreEnum.Type.mongo, 3, 4, 31),
(ModuleStoreEnum.Type.split, 3, 12, 32), (ModuleStoreEnum.Type.split, 3, 13, 31),
) )
@ddt.unpack @ddt.unpack
@count_queries @count_queries
...@@ -413,8 +413,8 @@ class ViewsQueryCountTestCase( ...@@ -413,8 +413,8 @@ class ViewsQueryCountTestCase(
self.create_thread_helper(mock_request) self.create_thread_helper(mock_request)
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 3, 28), (ModuleStoreEnum.Type.mongo, 3, 3, 27),
(ModuleStoreEnum.Type.split, 3, 9, 28), (ModuleStoreEnum.Type.split, 3, 10, 27),
) )
@ddt.unpack @ddt.unpack
@count_queries @count_queries
......
...@@ -21,7 +21,7 @@ class CourseGradeBase(object): ...@@ -21,7 +21,7 @@ class CourseGradeBase(object):
""" """
Base class for Course Grades. Base class for Course Grades.
""" """
def __init__(self, user, course_data, percent=0, letter_grade=None, passed=False): def __init__(self, user, course_data, percent=0, letter_grade=None, passed=False, force_update_subsections=False):
self.user = user self.user = user
self.course_data = course_data self.course_data = course_data
...@@ -30,6 +30,7 @@ class CourseGradeBase(object): ...@@ -30,6 +30,7 @@ class CourseGradeBase(object):
# Convert empty strings to None when reading from the table # Convert empty strings to None when reading from the table
self.letter_grade = letter_grade or None self.letter_grade = letter_grade or None
self.force_update_subsections = force_update_subsections
def __unicode__(self): def __unicode__(self):
return u'Course Grade: percent: {}, letter_grade: {}, passed: {}'.format( return u'Course Grade: percent: {}, letter_grade: {}, passed: {}'.format(
...@@ -203,7 +204,9 @@ class CourseGrade(CourseGradeBase): ...@@ -203,7 +204,9 @@ class CourseGrade(CourseGradeBase):
def update(self): def update(self):
""" """
Updates the grade for the course. Updates the grade for the course. Also updates subsection grades
if self.force_update_subsections is true, via the lazy call
to self.grader_result.
""" """
grade_cutoffs = self.course_data.course.grade_cutoffs grade_cutoffs = self.course_data.course.grade_cutoffs
self.percent = self._compute_percent(self.grader_result) self.percent = self._compute_percent(self.grader_result)
...@@ -224,7 +227,10 @@ class CourseGrade(CourseGradeBase): ...@@ -224,7 +227,10 @@ class CourseGrade(CourseGradeBase):
def _get_subsection_grade(self, subsection): def _get_subsection_grade(self, subsection):
# Pass read_only here so the subsection grades can be persisted in bulk at the end. # Pass read_only here so the subsection grades can be persisted in bulk at the end.
return self._subsection_grade_factory.create(subsection, read_only=True) if self.force_update_subsections:
return self._subsection_grade_factory.update(subsection)
else:
return self._subsection_grade_factory.create(subsection, read_only=True)
@staticmethod @staticmethod
def _compute_percent(grader_result): def _compute_percent(grader_result):
......
...@@ -66,7 +66,15 @@ class CourseGradeFactory(object): ...@@ -66,7 +66,15 @@ class CourseGradeFactory(object):
else: else:
return None return None
def update(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None): def update(
self,
user,
course=None,
collected_block_structure=None,
course_structure=None,
course_key=None,
force_update_subsections=False,
):
""" """
Computes, updates, and returns the CourseGrade for the given Computes, updates, and returns the CourseGrade for the given
user in the course. user in the course.
...@@ -75,7 +83,7 @@ class CourseGradeFactory(object): ...@@ -75,7 +83,7 @@ class CourseGradeFactory(object):
or course_key should be provided. or course_key should be provided.
""" """
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key) course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
return self._update(user, course_data, read_only=False) return self._update(user, course_data, read_only=False, force_update_subsections=force_update_subsections)
@contextmanager @contextmanager
def _course_transaction(self, course_key): def _course_transaction(self, course_key):
...@@ -118,10 +126,17 @@ class CourseGradeFactory(object): ...@@ -118,10 +126,17 @@ class CourseGradeFactory(object):
def _iter_grade_result(self, user, course_data, force_update): def _iter_grade_result(self, user, course_data, force_update):
try: try:
kwargs = {
'user': user,
'course': course_data.course,
'collected_block_structure': course_data.collected_structure,
'course_key': course_data.course_key
}
if force_update:
kwargs['force_update_subsections'] = True
method = CourseGradeFactory().update if force_update else CourseGradeFactory().create method = CourseGradeFactory().update if force_update else CourseGradeFactory().create
course_grade = method( course_grade = method(**kwargs)
user, course_data.course, course_data.collected_structure, course_key=course_data.course_key,
)
return self.GradeResult(user, course_grade, None) return self.GradeResult(user, course_grade, None)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
# Keep marching on even if this student couldn't be graded for # Keep marching on even if this student couldn't be graded for
...@@ -165,14 +180,14 @@ class CourseGradeFactory(object): ...@@ -165,14 +180,14 @@ class CourseGradeFactory(object):
return course_grade, persistent_grade.grading_policy_hash return course_grade, persistent_grade.grading_policy_hash
@staticmethod @staticmethod
def _update(user, course_data, read_only): def _update(user, course_data, read_only, force_update_subsections=False):
""" """
Computes, saves, and returns a CourseGrade object for the Computes, saves, and returns a CourseGrade object for the
given user and course. given user and course.
Sends a COURSE_GRADE_CHANGED signal to listeners and a Sends a COURSE_GRADE_CHANGED signal to listeners and a
COURSE_GRADE_NOW_PASSED if learner has passed course. COURSE_GRADE_NOW_PASSED if learner has passed course.
""" """
course_grade = CourseGrade(user, course_data) course_grade = CourseGrade(user, course_data, force_update_subsections=force_update_subsections)
course_grade.update() course_grade.update()
should_persist = ( should_persist = (
......
...@@ -106,7 +106,7 @@ def compute_grades_for_course_v2(self, **kwargs): ...@@ -106,7 +106,7 @@ def compute_grades_for_course_v2(self, **kwargs):
@task(base=_BaseTask) @task(base=_BaseTask)
def compute_grades_for_course(course_key, offset, batch_size, **kwargs): # pylint: disable=unused-argument def compute_grades_for_course(course_key, offset, batch_size, **kwargs): # pylint: disable=unused-argument
""" """
Compute grades for a set of students in the specified course. Compute and save grades for a set of students in the specified course.
The set of students will be determined by the order of enrollment date, and The set of students will be determined by the order of enrollment date, and
limited to at most <batch_size> students, starting from the specified limited to at most <batch_size> students, starting from the specified
......
...@@ -246,6 +246,20 @@ class TestCourseGradeFactory(GradeTestBase): ...@@ -246,6 +246,20 @@ class TestCourseGradeFactory(GradeTestBase):
else: else:
self.assertIsNone(course_grade) self.assertIsNone(course_grade)
@ddt.data(True, False)
def test_iter_force_update(self, force_update):
base_string = 'lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.{}'
desired_method_name = base_string.format('update' if force_update else 'create')
undesired_method_name = base_string.format('create' if force_update else 'update')
with patch(desired_method_name) as desired_call:
with patch(undesired_method_name) as undesired_call:
set(CourseGradeFactory().iter(
users=[self.request.user], course=self.course, force_update=force_update
))
self.assertTrue(desired_call.called)
self.assertFalse(undesired_call.called)
@ddt.ddt @ddt.ddt
class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase): class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
......
...@@ -25,7 +25,7 @@ def program_listing(request): ...@@ -25,7 +25,7 @@ def program_listing(request):
if not programs_config.enabled: if not programs_config.enabled:
raise Http404 raise Http404
meter = ProgramProgressMeter(request.user) meter = ProgramProgressMeter(request.site, request.user)
context = { context = {
'disable_courseware_js': True, 'disable_courseware_js': True,
...@@ -48,7 +48,7 @@ def program_details(request, program_uuid): ...@@ -48,7 +48,7 @@ def program_details(request, program_uuid):
if not programs_config.enabled: if not programs_config.enabled:
raise Http404 raise Http404
meter = ProgramProgressMeter(request.user, uuid=program_uuid) meter = ProgramProgressMeter(request.site, request.user, uuid=program_uuid)
program_data = meter.programs[0] program_data = meter.programs[0]
if not program_data: if not program_data:
......
...@@ -42,6 +42,10 @@ def mobile_course_access(depth=0): ...@@ -42,6 +42,10 @@ def mobile_course_access(depth=0):
except CoursewareAccessException as error: except CoursewareAccessException as error:
return Response(data=error.to_json(), status=status.HTTP_404_NOT_FOUND) return Response(data=error.to_json(), status=status.HTTP_404_NOT_FOUND)
except CourseAccessRedirect as error: 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 a 404 if the user does not have course access
raise Http404 raise Http404
return func(self, request, course=course, *args, **kwargs) return func(self, request, course=course, *args, **kwargs)
......
...@@ -4,7 +4,7 @@ Milestone related tests for the mobile_api ...@@ -4,7 +4,7 @@ Milestone related tests for the mobile_api
from django.conf import settings from django.conf import settings
from mock import patch 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 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 openedx.core.djangolib.testing.utils import get_mock_request
from util.milestones_helpers import add_prerequisite_course, fulfill_course_milestone from util.milestones_helpers import add_prerequisite_course, fulfill_course_milestone
...@@ -136,4 +136,4 @@ class MobileAPIMilestonesMixin(object): ...@@ -136,4 +136,4 @@ class MobileAPIMilestonesMixin(object):
self.api_response() self.api_response()
else: else:
response = self.api_response(expected_response_code=404) response = self.api_response(expected_response_code=404)
self.assertEqual(response.data, MilestoneError().to_json()) self.assertEqual(response.data, MilestoneAccessError().to_json())
...@@ -18,7 +18,7 @@ from certificates.api import generate_user_certificates ...@@ -18,7 +18,7 @@ from certificates.api import generate_user_certificates
from certificates.models import CertificateStatuses from certificates.models import CertificateStatuses
from certificates.tests.factories import GeneratedCertificateFactory from certificates.tests.factories import GeneratedCertificateFactory
from course_modes.models import CourseMode 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 lms.djangoapps.grades.tests.utils import mock_passing_grade
from mobile_api.testutils import ( from mobile_api.testutils import (
MobileAPITestCase, MobileAPITestCase,
...@@ -155,7 +155,7 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest ...@@ -155,7 +155,7 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest
] ]
expected_error_codes = [ expected_error_codes = [
MilestoneError().error_code, # 'unfulfilled_milestones' MilestoneAccessError().error_code, # 'unfulfilled_milestones'
StartDateError(self.NEXT_WEEK).error_code, # 'course_not_started' StartDateError(self.NEXT_WEEK).error_code, # 'course_not_started'
VisibilityError().error_code, # 'not_visible_to_user' VisibilityError().error_code, # 'not_visible_to_user'
None, None,
......
...@@ -8,7 +8,7 @@ from django.contrib.auth.models import User ...@@ -8,7 +8,7 @@ from django.contrib.auth.models import User
from django.test.client import Client from django.test.client import Client
from survey.models import SurveyForm 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.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -89,28 +89,28 @@ class SurveyModelsTests(ModuleStoreTestCase): ...@@ -89,28 +89,28 @@ class SurveyModelsTests(ModuleStoreTestCase):
""" """
Assert that a new course which has a required survey but user has not answered it yet 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( temp_course = CourseFactory.create(
course_survey_required=False 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( temp_course = CourseFactory.create(
course_survey_required=True, course_survey_required=True,
course_survey_name="NonExisting" 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): def test_user_has_answered_required_survey(self):
""" """
Assert that a new course which has a required survey and user has answers for it 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.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): def test_staff_must_answer_survey(self):
""" """
Assert that someone with staff level permissions does not have to answer the survey 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))
""" """
Helper methods for Surveys Utilities for determining whether or not a survey needs to be completed.
""" """
from courseware.access import has_access 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): def is_survey_required_for_course(course_descriptor):
...@@ -11,17 +10,19 @@ 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 Returns whether a Survey is required for this course
""" """
# check to see that the Survey name has been defined in the CourseDescriptor # Check to see that the survey is required in the CourseDescriptor.
# and that the specified Survey exists if not getattr(course_descriptor, 'course_survey_required', False):
return False
return course_descriptor.course_survey_required and \ # Check that the specified Survey for the course exists.
SurveyForm.get(course_descriptor.course_survey_name, throw_if_not_found=False) 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): if not is_survey_required_for_course(course_descriptor):
return False return False
...@@ -29,13 +30,13 @@ def must_answer_survey(course_descriptor, user): ...@@ -29,13 +30,13 @@ def must_answer_survey(course_descriptor, user):
if user.is_anonymous(): if user.is_anonymous():
return False return False
# this will throw exception if not found, but a non existing survey name will # course staff do not need to answer survey
# be trapped in the above is_survey_required_for_course() method
survey = SurveyForm.get(course_descriptor.course_survey_name)
has_staff_access = has_access(user, 'staff', course_descriptor) 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 # 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) answered_survey = SurveyAnswer.do_survey_answers_exist(survey, user)
return not answered_survey and not has_staff_access if not answered_survey:
return True
...@@ -580,14 +580,6 @@ DATADOG.update(ENV_TOKENS.get("DATADOG", {})) ...@@ -580,14 +580,6 @@ DATADOG.update(ENV_TOKENS.get("DATADOG", {}))
if 'DATADOG_API' in AUTH_TOKENS: if 'DATADOG_API' in AUTH_TOKENS:
DATADOG['api_key'] = AUTH_TOKENS['DATADOG_API'] 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
ANALYTICS_DASHBOARD_URL = ENV_TOKENS.get("ANALYTICS_DASHBOARD_URL", ANALYTICS_DASHBOARD_URL) ANALYTICS_DASHBOARD_URL = ENV_TOKENS.get("ANALYTICS_DASHBOARD_URL", ANALYTICS_DASHBOARD_URL)
ANALYTICS_DASHBOARD_NAME = ENV_TOKENS.get("ANALYTICS_DASHBOARD_NAME", PLATFORM_NAME + " Insights") ANALYTICS_DASHBOARD_NAME = ENV_TOKENS.get("ANALYTICS_DASHBOARD_NAME", PLATFORM_NAME + " Insights")
......
{ {
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "", "AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "", "AWS_SECRET_ACCESS_KEY": "",
"CC_PROCESSOR_NAME": "CyberSource2", "CC_PROCESSOR_NAME": "CyberSource2",
......
{ {
"ANALYTICS_SERVER_URL": "",
"ANALYTICS_DASHBOARD_URL": "", "ANALYTICS_DASHBOARD_URL": "",
"BOOK_URL": "", "BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com", "BUGS_EMAIL": "bugs@example.com",
......
{ {
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "", "AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "", "AWS_SECRET_ACCESS_KEY": "",
"CC_PROCESSOR_NAME": "CyberSource2", "CC_PROCESSOR_NAME": "CyberSource2",
......
{ {
"ANALYTICS_SERVER_URL": "",
"ANALYTICS_DASHBOARD_URL": "", "ANALYTICS_DASHBOARD_URL": "",
"BOOK_URL": "", "BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com", "BUGS_EMAIL": "bugs@example.com",
......
...@@ -2846,9 +2846,7 @@ ADVANCED_SECURITY_CONFIG = {} ...@@ -2846,9 +2846,7 @@ ADVANCED_SECURITY_CONFIG = {}
SHIBBOLETH_DOMAIN_PREFIX = 'shib:' SHIBBOLETH_DOMAIN_PREFIX = 'shib:'
OPENID_DOMAIN_PREFIX = 'openid:' OPENID_DOMAIN_PREFIX = 'openid:'
### Analytics Data API + Dashboard (Insights) settings ### Analytics Dashboard (Insights) settings
ANALYTICS_DATA_URL = ""
ANALYTICS_DATA_TOKEN = ""
ANALYTICS_DASHBOARD_URL = "" ANALYTICS_DASHBOARD_URL = ""
ANALYTICS_DASHBOARD_NAME = PLATFORM_NAME + " Insights" ANALYTICS_DASHBOARD_NAME = PLATFORM_NAME + " Insights"
......
...@@ -234,11 +234,6 @@ FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True ...@@ -234,11 +234,6 @@ FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True
PIPELINE_SASS_ARGUMENTS = '--debug-info' PIPELINE_SASS_ARGUMENTS = '--debug-info'
########################## ANALYTICS TESTING ########################
ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
ANALYTICS_API_KEY = ""
##### Segment ###### ##### Segment ######
# If there's an environment variable set, grab it # If there's an environment variable set, grab it
......
...@@ -43,9 +43,6 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' ...@@ -43,9 +43,6 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
########################## ANALYTICS TESTING ######################## ########################## 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 # Set this to the dashboard URL in order to display the link from the
# dashboard to the Analytics Dashboard. # dashboard to the Analytics Dashboard.
ANALYTICS_DASHBOARD_URL = None ANALYTICS_DASHBOARD_URL = None
......
// ------------------------------
// 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
.welcome-message { .welcome-message {
border: solid 1px $lms-border-color; border: solid 1px $lms-border-color;
......
...@@ -11,6 +11,10 @@ ...@@ -11,6 +11,10 @@
// ---------------------------- // ----------------------------
$lms-max-width: 1180px !default; $lms-max-width: 1180px !default;
$grid-breakpoints-sm: 576px !default;
$grid-breakpoints-md: 768px !default;
$grid-breakpoints-lg: 992px !default;
// ---------------------------- // ----------------------------
// #COLORS // #COLORS
// ---------------------------- // ----------------------------
......
...@@ -7,11 +7,11 @@ ...@@ -7,11 +7,11 @@
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML 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: % if banner_messages:
......
## mako ## mako
## Override the default styles_version to use Bootstrap
<%! main_css = "css/bootstrap/lms-main.css" %>
<%page expression_filter="h"/> <%page expression_filter="h"/>
<%inherit file="/main.html" /> <%inherit file="/main.html" />
......
...@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase): ...@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase):
self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.') self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.')
# Send empty data dictionary. # Send empty data dictionary.
with self.assertNumQueries(8): # No queries for bookmark table. with self.assertNumQueries(7): # No queries for bookmark table.
response = self.send_post( response = self.send_post(
client=self.client, client=self.client,
url=reverse('bookmarks'), url=reverse('bookmarks'),
......
...@@ -50,6 +50,7 @@ class Command(BaseCommand): ...@@ -50,6 +50,7 @@ class Command(BaseCommand):
site_config = getattr(site, 'configuration', None) site_config = getattr(site, 'configuration', None)
if site_config is None or not site_config.get_value('COURSE_CATALOG_API_URL'): if site_config is None or not site_config.get_value('COURSE_CATALOG_API_URL'):
logger.info('Skipping site {domain}. No configuration.'.format(domain=site.domain)) logger.info('Skipping site {domain}. No configuration.'.format(domain=site.domain))
cache.set(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), [], None)
continue continue
client = create_catalog_api_client(user, site=site) client = create_catalog_api_client(user, site=site)
......
"""Tests covering utilities for integrating with the catalog service.""" """Tests covering utilities for integrating with the catalog service."""
# pylint: disable=missing-docstring # pylint: disable=missing-docstring
import copy import copy
import uuid
import ddt import ddt
import mock import mock
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase, override_settings 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.cache import PROGRAM_CACHE_KEY_TPL, PROGRAM_UUIDS_CACHE_KEY
from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.catalog.models import CatalogIntegration
...@@ -19,8 +19,8 @@ from openedx.core.djangoapps.catalog.utils import ( ...@@ -19,8 +19,8 @@ from openedx.core.djangoapps.catalog.utils import (
get_programs, get_programs,
get_programs_with_type 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 openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from student.tests.factories import UserFactory
UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils' UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils'
User = get_user_model() # pylint: disable=invalid-name User = get_user_model() # pylint: disable=invalid-name
...@@ -32,6 +32,10 @@ User = get_user_model() # pylint: disable=invalid-name ...@@ -32,6 +32,10 @@ User = get_user_model() # pylint: disable=invalid-name
class TestGetPrograms(CacheIsolationTestCase): class TestGetPrograms(CacheIsolationTestCase):
ENABLED_CACHES = ['default'] ENABLED_CACHES = ['default']
def setUp(self):
super(TestGetPrograms, self).setUp()
self.site = SiteFactory()
def test_get_many(self, mock_warning, mock_info): def test_get_many(self, mock_warning, mock_info):
programs = ProgramFactory.create_batch(3) programs = ProgramFactory.create_batch(3)
...@@ -43,7 +47,7 @@ class TestGetPrograms(CacheIsolationTestCase): ...@@ -43,7 +47,7 @@ class TestGetPrograms(CacheIsolationTestCase):
# When called before UUIDs are cached, the function should return an # When called before UUIDs are cached, the function should return an
# empty list and log a warning. # 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.assert_called_once_with('Failed to get program UUIDs from the cache.')
mock_warning.reset_mock() mock_warning.reset_mock()
...@@ -54,7 +58,7 @@ class TestGetPrograms(CacheIsolationTestCase): ...@@ -54,7 +58,7 @@ class TestGetPrograms(CacheIsolationTestCase):
None None
) )
actual_programs = get_programs() actual_programs = get_programs(self.site)
# The 2 cached programs should be returned while info and warning # The 2 cached programs should be returned while info and warning
# messages should be logged for the missing one. # messages should be logged for the missing one.
...@@ -82,7 +86,7 @@ class TestGetPrograms(CacheIsolationTestCase): ...@@ -82,7 +86,7 @@ class TestGetPrograms(CacheIsolationTestCase):
} }
cache.set_many(all_programs, None) cache.set_many(all_programs, None)
actual_programs = get_programs() actual_programs = get_programs(self.site)
# All 3 programs should be returned. # All 3 programs should be returned.
self.assertEqual( self.assertEqual(
...@@ -116,7 +120,7 @@ class TestGetPrograms(CacheIsolationTestCase): ...@@ -116,7 +120,7 @@ class TestGetPrograms(CacheIsolationTestCase):
mock_cache.get.return_value = [program['uuid'] for program in programs] mock_cache.get.return_value = [program['uuid'] for program in programs]
mock_cache.get_many.side_effect = fake_get_many 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 # All 3 cached programs should be returned. An info message should be
# logged about the one that was initially missing, but the code should # logged about the one that was initially missing, but the code should
...@@ -136,7 +140,7 @@ class TestGetPrograms(CacheIsolationTestCase): ...@@ -136,7 +140,7 @@ class TestGetPrograms(CacheIsolationTestCase):
expected_program = ProgramFactory() expected_program = ProgramFactory()
expected_uuid = expected_program['uuid'] 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( mock_warning.assert_called_once_with(
'Failed to get details for program {uuid} from the cache.'.format(uuid=expected_uuid) 'Failed to get details for program {uuid} from the cache.'.format(uuid=expected_uuid)
) )
...@@ -148,7 +152,7 @@ class TestGetPrograms(CacheIsolationTestCase): ...@@ -148,7 +152,7 @@ class TestGetPrograms(CacheIsolationTestCase):
None None
) )
actual_program = get_programs(uuid=expected_uuid) actual_program = get_programs(self.site, uuid=expected_uuid)
self.assertEqual(actual_program, expected_program) self.assertEqual(actual_program, expected_program)
self.assertFalse(mock_warning.called) self.assertFalse(mock_warning.called)
...@@ -156,6 +160,9 @@ class TestGetPrograms(CacheIsolationTestCase): ...@@ -156,6 +160,9 @@ class TestGetPrograms(CacheIsolationTestCase):
@skip_unless_lms @skip_unless_lms
@ddt.ddt @ddt.ddt
class TestGetProgramsWithType(TestCase): class TestGetProgramsWithType(TestCase):
def setUp(self):
super(TestGetProgramsWithType, self).setUp()
self.site = SiteFactory()
@mock.patch(UTILS_MODULE + '.get_programs') @mock.patch(UTILS_MODULE + '.get_programs')
@mock.patch(UTILS_MODULE + '.get_program_types') @mock.patch(UTILS_MODULE + '.get_program_types')
...@@ -176,7 +183,7 @@ class TestGetProgramsWithType(TestCase): ...@@ -176,7 +183,7 @@ class TestGetProgramsWithType(TestCase):
mock_get_programs.return_value = programs mock_get_programs.return_value = programs
mock_get_program_types.return_value = program_types 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) self.assertEqual(actual, programs_with_program_type)
@ddt.data(False, True) @ddt.data(False, True)
...@@ -202,7 +209,7 @@ class TestGetProgramsWithType(TestCase): ...@@ -202,7 +209,7 @@ class TestGetProgramsWithType(TestCase):
mock_get_programs.return_value = programs mock_get_programs.return_value = programs
mock_get_program_types.return_value = program_types 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) self.assertEqual(actual, programs_with_program_type)
......
...@@ -14,7 +14,6 @@ from openedx.core.djangoapps.catalog.cache import ( ...@@ -14,7 +14,6 @@ from openedx.core.djangoapps.catalog.cache import (
SITE_PROGRAM_UUIDS_CACHE_KEY_TPL SITE_PROGRAM_UUIDS_CACHE_KEY_TPL
) )
from openedx.core.djangoapps.catalog.models import CatalogIntegration 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.edx_api_utils import get_edx_api_data
from openedx.core.lib.token_utils import JwtBuilder from openedx.core.lib.token_utils import JwtBuilder
...@@ -35,11 +34,14 @@ def create_catalog_api_client(user, site=None): ...@@ -35,11 +34,14 @@ def create_catalog_api_client(user, site=None):
return EdxRestApiClient(url, jwt=jwt) return EdxRestApiClient(url, jwt=jwt)
def get_programs(uuid=None): def get_programs(site, uuid=None):
"""Read programs from the cache. """Read programs from the cache.
The cache is populated by a management command, cache_programs. The cache is populated by a management command, cache_programs.
Arguments:
site (Site): django.contrib.sites.models object
Keyword Arguments: Keyword Arguments:
uuid (string): UUID identifying a specific program to read from the cache. uuid (string): UUID identifying a specific program to read from the cache.
...@@ -56,7 +58,7 @@ def get_programs(uuid=None): ...@@ -56,7 +58,7 @@ def get_programs(uuid=None):
return program return program
if waffle.switch_is_active('get-multitenant-programs'): 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: else:
uuids = cache.get(PROGRAM_UUIDS_CACHE_KEY, []) uuids = cache.get(PROGRAM_UUIDS_CACHE_KEY, [])
if not uuids: if not uuids:
...@@ -121,13 +123,16 @@ def get_program_types(name=None): ...@@ -121,13 +123,16 @@ def get_program_types(name=None):
return [] 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 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. include_hidden parameter. By default hidden programs will be included.
The program dict is updated with the fully serialized program type. The program dict is updated with the fully serialized program type.
Arguments:
site (Site): django.contrib.sites.models object
Keyword Arguments: Keyword Arguments:
include_hidden (bool): whether to include hidden programs include_hidden (bool): whether to include hidden programs
...@@ -135,7 +140,7 @@ def get_programs_with_type(include_hidden=True): ...@@ -135,7 +140,7 @@ def get_programs_with_type(include_hidden=True):
list of dict, representing the active programs. list of dict, representing the active programs.
""" """
programs_with_type = [] programs_with_type = []
programs = get_programs() programs = get_programs(site)
if programs: if programs:
program_types = {program_type['name']: program_type for program_type in get_program_types()} program_types = {program_type['name']: program_type for program_type in get_program_types()}
......
...@@ -7,12 +7,7 @@ from django.http import HttpResponseNotFound ...@@ -7,12 +7,7 @@ from django.http import HttpResponseNotFound
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from mako.exceptions import TopLevelLookupException from mako.exceptions import TopLevelLookupException
from openedx.core.djangoapps.util.user_messages import ( from openedx.core.djangoapps.util.user_messages import PageLevelMessages
register_error_message,
register_info_message,
register_success_message,
register_warning_message,
)
def show_reference_template(request, template): def show_reference_template(request, template):
...@@ -28,23 +23,34 @@ 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 e.g. /template/ux/reference/index.html?name=Foo
""" """
try: try:
uses_bootstrap = u'/bootstrap/' in request.path
uses_pattern_library = u'/pattern-library/' in request.path uses_pattern_library = u'/pattern-library/' in request.path
is_v1 = u'/v1/' in request.path is_v1 = u'/v1/' in request.path
uses_bootstrap = not uses_pattern_library and not is_v1
context = { context = {
"disable_courseware_js": not is_v1, 'request': request,
"uses_pattern_library": uses_pattern_library, 'disable_courseware_js': not is_v1,
"uses_bootstrap": uses_bootstrap, 'uses_pattern_library': uses_pattern_library,
'uses_bootstrap': uses_bootstrap,
} }
context.update(request.GET.dict()) 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 # Add some messages to the course skeleton pages
if u'course-skeleton.html' in request.path: if u'course-skeleton.html' in request.path:
register_info_message(request, _('This is a test message')) PageLevelMessages.register_info_message(request, _('This is a test message'))
register_success_message(request, _('This is a success message')) PageLevelMessages.register_success_message(request, _('This is a success message'))
register_warning_message(request, _('This is a test warning')) PageLevelMessages.register_warning_message(request, _('This is a test warning'))
register_error_message(request, _('This is a test error')) PageLevelMessages.register_error_message(request, _('This is a test error'))
return render_to_response(template, context) return render_to_response(template, context)
except TopLevelLookupException: except TopLevelLookupException:
return HttpResponseNotFound("Couldn't find template {template}".format(template=template)) return HttpResponseNotFound('Missing template {template}'.format(template=template))
"""Management command for backpopulating missing program credentials.""" """Management command for backpopulating missing program credentials."""
from collections import namedtuple
import logging 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.core.management import BaseCommand
from django.db.models import Q from django.db.models import Q
from opaque_keys.edx.keys import CourseKey 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 course_modes.models import CourseMode
from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates
# TODO: Log to console, even with debug mode disabled? # TODO: Log to console, even with debug mode disabled?
logger = logging.getLogger(__name__) # pylint: disable=invalid-name logger = logging.getLogger(__name__) # pylint: disable=invalid-name
CourseRun = namedtuple('CourseRun', ['key', 'type']) CourseRun = namedtuple('CourseRun', ['key', 'type'])
...@@ -73,7 +72,11 @@ class Command(BaseCommand): ...@@ -73,7 +72,11 @@ class Command(BaseCommand):
def _load_course_runs(self): def _load_course_runs(self):
"""Find all course runs which are part of a program.""" """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) self.course_runs = self._flatten(programs)
def _flatten(self, programs): def _flatten(self, programs):
......
...@@ -5,6 +5,7 @@ from celery import task ...@@ -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 celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sites.models import Site
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from edx_rest_api_client import exceptions from edx_rest_api_client import exceptions
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
...@@ -55,18 +56,19 @@ def get_api_client(api_config, student): ...@@ -55,18 +56,19 @@ def get_api_client(api_config, student):
return EdxRestApiClient(api_config.internal_api_url, jwt=jwt) 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. Given a set of completed courses, determine which programs are completed.
Args: Args:
site (Site): Site for which data should be retrieved.
student (User): Representing the student whose completed programs to check for. student (User): Representing the student whose completed programs to check for.
Returns: Returns:
list of program UUIDs list of program UUIDs
""" """
meter = ProgramProgressMeter(student) meter = ProgramProgressMeter(site, student)
return meter.completed_programs return meter.completed_programs
...@@ -80,7 +82,7 @@ def get_certified_programs(student): ...@@ -80,7 +82,7 @@ def get_certified_programs(student):
User object representing the student User object representing the student
Returns: 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 = [] certified_programs = []
...@@ -129,8 +131,7 @@ def award_program_certificates(self, username): ...@@ -129,8 +131,7 @@ def award_program_certificates(self, username):
student. student.
Args: Args:
username: username (str): The username of the student
The username of the student
Returns: Returns:
None None
...@@ -158,16 +159,16 @@ def award_program_certificates(self, username): ...@@ -158,16 +159,16 @@ def award_program_certificates(self, username):
LOGGER.exception('Task award_program_certificates was called with invalid username %s', username) LOGGER.exception('Task award_program_certificates was called with invalid username %s', username)
# Don't retry for this case - just conclude the task. # Don't retry for this case - just conclude the task.
return return
program_uuids = []
program_uuids = get_completed_programs(student) for site in Site.objects.all():
program_uuids.extend(get_completed_programs(site, student))
if not program_uuids: if not program_uuids:
# No reason to continue beyond this point unless/until this # No reason to continue beyond this point unless/until this
# task gets updated to support revocation of program certs. # 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) LOGGER.info('Task award_program_certificates was called for user %s with no completed programs', username)
return return
# Determine which program certificates the user has already been # Determine which program certificates the user has already been awarded, if any.
# awarded, if any.
existing_program_uuids = get_certified_programs(student) existing_program_uuids = get_certified_programs(student)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
......
...@@ -16,6 +16,7 @@ from edx_rest_api_client.client import EdxRestApiClient ...@@ -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.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangoapps.programs.tasks.v1 import tasks 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 openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
...@@ -130,6 +131,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo ...@@ -130,6 +131,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
super(AwardProgramCertificatesTestCase, self).setUp() super(AwardProgramCertificatesTestCase, self).setUp()
self.create_credentials_config() self.create_credentials_config()
self.student = UserFactory.create(username='test-student') self.student = UserFactory.create(username='test-student')
self.site = SiteFactory()
self.catalog_integration = self.create_catalog_integration() self.catalog_integration = self.create_catalog_integration()
ClientFactory.create(name='credentials') ClientFactory.create(name='credentials')
...@@ -146,7 +148,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo ...@@ -146,7 +148,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
programs. programs.
""" """
tasks.award_program_certificates.delay(self.student.username).get() 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( @ddt.data(
([1], [2, 3]), ([1], [2, 3]),
...@@ -282,7 +284,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo ...@@ -282,7 +284,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
""" """
mock_get_completed_programs.side_effect = self._make_side_effect([Exception('boom'), None]) mock_get_completed_programs.side_effect = self._make_side_effect([Exception('boom'), None])
tasks.award_program_certificates.delay(self.student.username).get() 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( def test_retry_on_credentials_api_errors(
self, self,
......
...@@ -10,8 +10,8 @@ from student.tests.factories import UserFactory ...@@ -10,8 +10,8 @@ from student.tests.factories import UserFactory
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
from openedx.core.djangoapps.programs.signals import handle_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' TEST_USERNAME = 'test-user'
......
...@@ -33,6 +33,7 @@ from openedx.core.djangoapps.programs.utils import ( ...@@ -33,6 +33,7 @@ from openedx.core.djangoapps.programs.utils import (
ProgramMarketingDataExtender, ProgramMarketingDataExtender,
get_certificates, get_certificates,
) )
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import AnonymousUserFactory, UserFactory, CourseEnrollmentFactory from student.tests.factories import AnonymousUserFactory, UserFactory, CourseEnrollmentFactory
from util.date_utils import strftime_localized from util.date_utils import strftime_localized
...@@ -55,6 +56,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -55,6 +56,7 @@ class TestProgramProgressMeter(TestCase):
super(TestProgramProgressMeter, self).setUp() super(TestProgramProgressMeter, self).setUp()
self.user = UserFactory() self.user = UserFactory()
self.site = SiteFactory()
def _create_enrollments(self, *course_run_ids): def _create_enrollments(self, *course_run_ids):
"""Variadic helper used to create course run enrollments.""" """Variadic helper used to create course run enrollments."""
...@@ -92,7 +94,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -92,7 +94,7 @@ class TestProgramProgressMeter(TestCase):
data = [ProgramFactory()] data = [ProgramFactory()]
mock_get_programs.return_value = data mock_get_programs.return_value = data
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.engaged_programs, []) self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter) self._assert_progress(meter)
...@@ -104,7 +106,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -104,7 +106,7 @@ class TestProgramProgressMeter(TestCase):
course_run_id = generate_course_run_key() course_run_id = generate_course_run_key()
self._create_enrollments(course_run_id) self._create_enrollments(course_run_id)
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.engaged_programs, []) self.assertEqual(meter.engaged_programs, [])
self._assert_progress(meter) self._assert_progress(meter)
...@@ -129,7 +131,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -129,7 +131,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = data mock_get_programs.return_value = data
self._create_enrollments(course_run_key) self._create_enrollments(course_run_key)
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self._attach_detail_url(data) self._attach_detail_url(data)
program = data[0] program = data[0]
...@@ -159,7 +161,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -159,7 +161,7 @@ class TestProgramProgressMeter(TestCase):
self._create_enrollments(course_run_key) self._create_enrollments(course_run_key)
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
program = data[0] program = data[0]
expected = [ expected = [
...@@ -195,7 +197,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -195,7 +197,7 @@ class TestProgramProgressMeter(TestCase):
mode=CourseMode.NO_ID_PROFESSIONAL_MODE mode=CourseMode.NO_ID_PROFESSIONAL_MODE
) )
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
program = data[0] program = data[0]
expected = [ expected = [
...@@ -236,7 +238,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -236,7 +238,7 @@ class TestProgramProgressMeter(TestCase):
CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit') CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit')
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
program = data[0] program = data[0]
expected = [ expected = [
...@@ -278,7 +280,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -278,7 +280,7 @@ class TestProgramProgressMeter(TestCase):
# The creation time of the enrollments matters to the test. We want # The creation time of the enrollments matters to the test. We want
# the first_course_run_key to represent the newest enrollment. # the first_course_run_key to represent the newest enrollment.
self._create_enrollments(older_course_run_key, newer_course_run_key) 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) self._attach_detail_url(data)
programs = data[:2] programs = data[:2]
...@@ -323,7 +325,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -323,7 +325,7 @@ class TestProgramProgressMeter(TestCase):
# Enrollment for the shared course run created last (most recently). # Enrollment for the shared course run created last (most recently).
self._create_enrollments(solo_course_run_key, shared_course_run_key) 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) self._attach_detail_url(data)
programs = data[:3] programs = data[:3]
...@@ -354,13 +356,13 @@ class TestProgramProgressMeter(TestCase): ...@@ -354,13 +356,13 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = data mock_get_programs.return_value = data
# No enrollments, no programs in progress. # No enrollments, no programs in progress.
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress(meter) self._assert_progress(meter)
self.assertEqual(meter.completed_programs, []) self.assertEqual(meter.completed_programs, [])
# One enrollment, one program in progress. # One enrollment, one program in progress.
self._create_enrollments(first_course_run_key) 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'] program, program_uuid = data[0], data[0]['uuid']
self._assert_progress( self._assert_progress(
meter, meter,
...@@ -370,7 +372,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -370,7 +372,7 @@ class TestProgramProgressMeter(TestCase):
# Two enrollments, all courses in progress. # Two enrollments, all courses in progress.
self._create_enrollments(second_course_run_key) self._create_enrollments(second_course_run_key)
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress( self._assert_progress(
meter, meter,
ProgressFactory(uuid=program_uuid, in_progress=2) ProgressFactory(uuid=program_uuid, in_progress=2)
...@@ -381,7 +383,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -381,7 +383,7 @@ class TestProgramProgressMeter(TestCase):
mock_completed_course_runs.return_value = [ mock_completed_course_runs.return_value = [
{'course_run_id': first_course_run_key, 'type': MODES.verified}, {'course_run_id': first_course_run_key, 'type': MODES.verified},
] ]
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress( self._assert_progress(
meter, meter,
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1) ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
...@@ -393,7 +395,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -393,7 +395,7 @@ class TestProgramProgressMeter(TestCase):
{'course_run_id': first_course_run_key, 'type': MODES.verified}, {'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_course_run_key, 'type': MODES.honor}, {'course_run_id': second_course_run_key, 'type': MODES.honor},
] ]
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self._assert_progress( self._assert_progress(
meter, meter,
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1) ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
...@@ -405,7 +407,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -405,7 +407,7 @@ class TestProgramProgressMeter(TestCase):
{'course_run_id': first_course_run_key, 'type': MODES.verified}, {'course_run_id': first_course_run_key, 'type': MODES.verified},
{'course_run_id': second_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( self._assert_progress(
meter, meter,
ProgressFactory(uuid=program_uuid, completed=2) ProgressFactory(uuid=program_uuid, completed=2)
...@@ -436,7 +438,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -436,7 +438,7 @@ class TestProgramProgressMeter(TestCase):
mock_completed_course_runs.return_value = [ mock_completed_course_runs.return_value = [
{'course_run_id': course_run_key, 'type': MODES.honor}, {'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'] program, program_uuid = data[0], data[0]['uuid']
self._assert_progress( self._assert_progress(
...@@ -449,7 +451,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -449,7 +451,7 @@ class TestProgramProgressMeter(TestCase):
"""Verify that programs with no courses do not count as completed.""" """Verify that programs with no courses do not count as completed."""
program = ProgramFactory() program = ProgramFactory()
program['courses'] = [] program['courses'] = []
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
program_complete = meter._is_program_complete(program) program_complete = meter._is_program_complete(program)
self.assertFalse(program_complete) self.assertFalse(program_complete)
...@@ -469,7 +471,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -469,7 +471,7 @@ class TestProgramProgressMeter(TestCase):
course_run_keys.append(course_run['key']) course_run_keys.append(course_run['key'])
# Verify that no programs are complete. # Verify that no programs are complete.
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, []) self.assertEqual(meter.completed_programs, [])
# Complete all programs. # Complete all programs.
...@@ -480,7 +482,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -480,7 +482,7 @@ class TestProgramProgressMeter(TestCase):
] ]
# Verify that all programs are complete. # Verify that all programs are complete.
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, program_uuids) self.assertEqual(meter.completed_programs, program_uuids)
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user') @mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
...@@ -494,7 +496,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -494,7 +496,7 @@ class TestProgramProgressMeter(TestCase):
self._make_certificate_result(status='unknown', course_key='unknown-course'), self._make_certificate_result(status='unknown', course_key='unknown-course'),
] ]
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual( self.assertEqual(
meter.completed_course_runs, meter.completed_course_runs,
[ [
...@@ -517,7 +519,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -517,7 +519,7 @@ class TestProgramProgressMeter(TestCase):
mock_get_programs.return_value = [program] mock_get_programs.return_value = [program]
# Verify that the test program is not complete. # Verify that the test program is not complete.
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, []) self.assertEqual(meter.completed_programs, [])
# Grant a 'no-id-professional' certificate for one of the course runs, # Grant a 'no-id-professional' certificate for one of the course runs,
...@@ -527,7 +529,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -527,7 +529,7 @@ class TestProgramProgressMeter(TestCase):
] ]
# Verify that the program is complete. # Verify that the program is complete.
meter = ProgramProgressMeter(self.user) meter = ProgramProgressMeter(self.site, self.user)
self.assertEqual(meter.completed_programs, [program['uuid']]) self.assertEqual(meter.completed_programs, [program['uuid']])
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock) @mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
...@@ -543,7 +545,7 @@ class TestProgramProgressMeter(TestCase): ...@@ -543,7 +545,7 @@ class TestProgramProgressMeter(TestCase):
program = ProgramFactory(courses=[course]) program = ProgramFactory(courses=[course])
mock_get_programs.return_value = [program] mock_get_programs.return_value = [program]
self._create_enrollments(course_run_key) 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'}] mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': 'verified'}]
self.assertEqual(meter._is_course_complete(course), True) self.assertEqual(meter._is_course_complete(course), True)
......
...@@ -70,7 +70,8 @@ class ProgramProgressMeter(object): ...@@ -70,7 +70,8 @@ class ProgramProgressMeter(object):
will only inspect this one program, not all programs the user may be will only inspect this one program, not all programs the user may be
engaged with. 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.user = user
self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user)) self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
...@@ -89,9 +90,9 @@ class ProgramProgressMeter(object): ...@@ -89,9 +90,9 @@ class ProgramProgressMeter(object):
self.course_run_ids.append(enrollment_id) self.course_run_ids.append(enrollment_id)
if uuid: if uuid:
self.programs = [get_programs(uuid=uuid)] self.programs = [get_programs(self.site, uuid=uuid)]
else: else:
self.programs = attach_program_detail_url(get_programs()) self.programs = attach_program_detail_url(get_programs(self.site))
def invert_programs(self): def invert_programs(self):
"""Intersect programs and enrollments. """Intersect programs and enrollments.
......
...@@ -26,5 +26,7 @@ class SiteFactory(DjangoModelFactory): ...@@ -26,5 +26,7 @@ class SiteFactory(DjangoModelFactory):
model = Site model = Site
django_get_or_create = ('domain',) 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' domain = 'testserver.fake'
name = 'testserver.fake' name = 'testserver.fake'
...@@ -174,7 +174,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -174,7 +174,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
Test that a client (logged in) can get her own username. Test that a client (logged in) can get her own username.
""" """
self.client.login(username=self.user.username, password=TEST_PASSWORD) self.client.login(username=self.user.username, password=TEST_PASSWORD)
self._verify_get_own_username(15) self._verify_get_own_username(14)
def test_get_username_inactive(self): def test_get_username_inactive(self):
""" """
...@@ -184,7 +184,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -184,7 +184,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
self.client.login(username=self.user.username, password=TEST_PASSWORD) self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.user.is_active = False self.user.is_active = False
self.user.save() self.user.save()
self._verify_get_own_username(15) self._verify_get_own_username(14)
def test_get_username_not_logged_in(self): def test_get_username_not_logged_in(self):
""" """
...@@ -193,7 +193,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -193,7 +193,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
# verify that the endpoint is inaccessible when not logged in # verify that the endpoint is inaccessible when not logged in
self._verify_get_own_username(13, expected_status=401) self._verify_get_own_username(12, expected_status=401)
@ddt.ddt @ddt.ddt
...@@ -305,7 +305,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -305,7 +305,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user) self.create_mock_profile(self.user)
with self.assertNumQueries(19): with self.assertNumQueries(18):
response = self.send_get(self.different_client) response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY) self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
...@@ -320,7 +320,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -320,7 +320,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
""" """
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user) self.create_mock_profile(self.user)
with self.assertNumQueries(19): with self.assertNumQueries(18):
response = self.send_get(self.different_client) response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY) self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
...@@ -395,12 +395,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -395,12 +395,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
self.assertEqual(False, data["accomplishments_shared"]) self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=TEST_PASSWORD) self.client.login(username=self.user.username, password=TEST_PASSWORD)
verify_get_own_information(17) verify_get_own_information(16)
# Now make sure that the user can get the same information, even if not active # Now make sure that the user can get the same information, even if not active
self.user.is_active = False self.user.is_active = False
self.user.save() self.user.save()
verify_get_own_information(11) verify_get_own_information(10)
def test_get_account_empty_string(self): def test_get_account_empty_string(self):
""" """
...@@ -414,7 +414,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): ...@@ -414,7 +414,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.save() legacy_profile.save()
self.client.login(username=self.user.username, password=TEST_PASSWORD) self.client.login(username=self.user.username, password=TEST_PASSWORD)
with self.assertNumQueries(17): with self.assertNumQueries(16):
response = self.send_get(self.client) response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country", "bio"): for empty_field in ("level_of_education", "gender", "country", "bio"):
self.assertIsNone(response.data[empty_field]) self.assertIsNone(response.data[empty_field])
......
...@@ -10,15 +10,7 @@ from django.test import RequestFactory ...@@ -10,15 +10,7 @@ from django.test import RequestFactory
from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.markup import HTML, Text
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from ..user_messages import ( from ..user_messages import PageLevelMessages, UserMessageType
register_error_message,
register_info_message,
register_success_message,
register_user_message,
register_warning_message,
user_messages,
UserMessageType,
)
TEST_MESSAGE = 'Test message' TEST_MESSAGE = 'Test message'
...@@ -26,7 +18,7 @@ TEST_MESSAGE = 'Test message' ...@@ -26,7 +18,7 @@ TEST_MESSAGE = 'Test message'
@ddt.ddt @ddt.ddt
class UserMessagesTestCase(TestCase): class UserMessagesTestCase(TestCase):
""" """
Unit tests for user messages. Unit tests for page level user messages.
""" """
def setUp(self): def setUp(self):
super(UserMessagesTestCase, self).setUp() super(UserMessagesTestCase, self).setUp()
...@@ -46,8 +38,8 @@ class UserMessagesTestCase(TestCase): ...@@ -46,8 +38,8 @@ class UserMessagesTestCase(TestCase):
""" """
Verifies that a user message is escaped correctly. Verifies that a user message is escaped correctly.
""" """
register_user_message(self.request, UserMessageType.INFO, message) PageLevelMessages.register_user_message(self.request, UserMessageType.INFO, message)
messages = list(user_messages(self.request)) messages = list(PageLevelMessages.user_messages(self.request))
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
self.assertEquals(messages[0].message_html, expected_message_html) self.assertEquals(messages[0].message_html, expected_message_html)
...@@ -62,17 +54,17 @@ class UserMessagesTestCase(TestCase): ...@@ -62,17 +54,17 @@ class UserMessagesTestCase(TestCase):
""" """
Verifies that a user message returns the correct CSS and icon classes. Verifies that a user message returns the correct CSS and icon classes.
""" """
register_user_message(self.request, message_type, TEST_MESSAGE) PageLevelMessages.register_user_message(self.request, message_type, TEST_MESSAGE)
messages = list(user_messages(self.request)) messages = list(PageLevelMessages.user_messages(self.request))
self.assertEqual(len(messages), 1) self.assertEqual(len(messages), 1)
self.assertEquals(messages[0].css_class, expected_css_class) self.assertEquals(messages[0].css_class, expected_css_class)
self.assertEquals(messages[0].icon_class, expected_icon_class) self.assertEquals(messages[0].icon_class, expected_icon_class)
@ddt.data( @ddt.data(
(register_error_message, UserMessageType.ERROR), (PageLevelMessages.register_error_message, UserMessageType.ERROR),
(register_info_message, UserMessageType.INFO), (PageLevelMessages.register_info_message, UserMessageType.INFO),
(register_success_message, UserMessageType.SUCCESS), (PageLevelMessages.register_success_message, UserMessageType.SUCCESS),
(register_warning_message, UserMessageType.WARNING), (PageLevelMessages.register_warning_message, UserMessageType.WARNING),
) )
@ddt.unpack @ddt.unpack
def test_message_type(self, register_message_function, expected_message_type): def test_message_type(self, register_message_function, expected_message_type):
...@@ -80,6 +72,6 @@ class UserMessagesTestCase(TestCase): ...@@ -80,6 +72,6 @@ class UserMessagesTestCase(TestCase):
Verifies that each user message function returns the correct type. Verifies that each user message function returns the correct type.
""" """
register_message_function(self.request, TEST_MESSAGE) 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.assertEqual(len(messages), 1)
self.assertEquals(messages[0].type, expected_message_type) self.assertEquals(messages[0].type, expected_message_type)
...@@ -14,12 +14,12 @@ There are two common use cases: ...@@ -14,12 +14,12 @@ There are two common use cases:
used to show a success message to the use. used to show a success message to the use.
""" """
from abc import abstractmethod
from enum import Enum from enum import Enum
from django.contrib import messages from django.contrib import messages
from openedx.core.djangolib.markup import Text from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import Text, HTML
EDX_USER_MESSAGE_TAG = 'edx-user-message'
class UserMessageType(Enum): class UserMessageType(Enum):
...@@ -49,7 +49,7 @@ ICON_CLASSES = { ...@@ -49,7 +49,7 @@ ICON_CLASSES = {
class UserMessage(): 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): def __init__(self, type, message_html):
assert isinstance(type, UserMessageType) assert isinstance(type, UserMessageType)
...@@ -67,71 +67,124 @@ class UserMessage(): ...@@ -67,71 +67,124 @@ class UserMessage():
def icon_class(self): def icon_class(self):
""" """
Returns the CSS icon class representing the message type. Returns the CSS icon class representing the message type.
Returns:
""" """
return ICON_CLASSES[self.type] 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) @classmethod
messages.add_message(request, message_type.value, Text(message), extra_tags=EDX_USER_MESSAGE_TAG) @abstractmethod
def get_namespace(self):
"""
Returns the namespace of the message collection.
The name is used to namespace the subset of django messages.
For example, return 'course_home_messages'.
"""
raise NotImplementedError('Subclasses must define a namespace for messages.')
def register_info_message(request, message, **kwargs): @classmethod
""" def get_message_html(self, body_html, title=None):
Registers an information message to be shown to the user. """
""" Returns the entire HTML snippet for the message.
register_user_message(request, UserMessageType.INFO, message, **kwargs)
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('<div class="message-header">'),
title=title,
body=body_html,
header_close=HTML('</div>')
)
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.
def register_success_message(request, message, **kwargs): Arguments:
""" message_type (UserMessageType): the user message type
Registers a success message to be shown to the user. body_html (str): body of the message in html format
""" title (str): optional title for the message as plain text
register_user_message(request, UserMessageType.SUCCESS, message, **kwargs) """
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)
def register_warning_message(request, message, **kwargs): @classmethod
""" def register_success_message(self, request, message, **kwargs):
Registers a warning message to be shown to the user. """
""" Registers a success message to be shown to the user.
register_user_message(request, UserMessageType.WARNING, message, **kwargs) """
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)
def register_error_message(request, message, **kwargs): @classmethod
""" def register_error_message(self, request, message, **kwargs):
Registers an error message to be shown to the user. """
""" Registers an error message to be shown to the user.
register_user_message(request, UserMessageType.ERROR, message, **kwargs) """
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)
def user_messages(request):
"""
Returns any outstanding user messages.
Note: this function also marks these messages as being complete class PageLevelMessages(UserMessageCollection):
so they won't be returned in the next request.
""" """
def _get_message_type_for_level(level): This set of messages appears as top page level messages.
""" """
Returns the user message type associated with a level. NAMESPACE = 'page_level_messages'
"""
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): @classmethod
def get_namespace(self):
""" """
Creates a user message from a Django message. Returns the namespace of the message collection.
""" """
return UserMessage( return self.NAMESPACE
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 EDX_USER_MESSAGE_TAG in message.tags)
...@@ -14,6 +14,13 @@ class CourseBookmarksTool(CourseTool): ...@@ -14,6 +14,13 @@ class CourseBookmarksTool(CourseTool):
The course bookmarks tool. The course bookmarks tool.
""" """
@classmethod @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): def is_enabled(cls, request, course_key):
""" """
The bookmarks tool is only enabled for enrolled users or staff. The bookmarks tool is only enabled for enrolled users or staff.
......
...@@ -3,7 +3,8 @@ Unified course experience settings and helper methods. ...@@ -3,7 +3,8 @@ Unified course experience settings and helper methods.
""" """
from django.utils.translation import ugettext as _ 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. # Namespace for course experience waffle flags.
...@@ -58,3 +59,17 @@ def course_home_url_name(course_key): ...@@ -58,3 +59,17 @@ def course_home_url_name(course_key):
return 'openedx.course_experience.course_home' return 'openedx.course_experience.course_home'
else: else:
return 'info' 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
...@@ -16,6 +16,14 @@ class CourseTool(object): ...@@ -16,6 +16,14 @@ class CourseTool(object):
not a requirement, and plugin implementations outside of this repo should not a requirement, and plugin implementations outside of this repo should
simply follow the contract defined below. 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 @classmethod
def is_enabled(cls, request, course_key): def is_enabled(cls, request, course_key):
......
...@@ -20,6 +20,13 @@ class CourseUpdatesTool(CourseTool): ...@@ -20,6 +20,13 @@ class CourseUpdatesTool(CourseTool):
The course updates tool. The course updates tool.
""" """
@classmethod @classmethod
def analytics_id(cls):
"""
Returns an analytics id for this tool, used for eventing.
"""
return 'edx.updates'
@classmethod
def title(cls): def title(cls):
""" """
Returns the title of this tool. Returns the title of this tool.
...@@ -58,6 +65,13 @@ class CourseReviewsTool(CourseTool): ...@@ -58,6 +65,13 @@ class CourseReviewsTool(CourseTool):
The course reviews tool. The course reviews tool.
""" """
@classmethod @classmethod
def analytics_id(cls):
"""
Returns an id to uniquely identify this tool in analytics events.
"""
return 'edx.reviews'
@classmethod
def title(cls): def title(cls):
""" """
Returns the title of this tool. Returns the title of this tool.
......
...@@ -67,19 +67,19 @@ ...@@ -67,19 +67,19 @@
<h3 class="hd-6">Course Tools</h3> <h3 class="hd-6">Course Tools</h3>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li> <li>
<a class="course-tool-link" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/bookmarks/"> <a class="course-tool-link" data-analytics-id="edx.bookmarks" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/bookmarks/">
<span class="icon fa fa-bookmark" aria-hidden="true"></span> <span class="icon fa fa-bookmark" aria-hidden="true"></span>
Bookmarks Bookmarks
</a> </a>
</li> </li>
<li> <li>
<a class="course-tool-link" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/course/reviews"> <a class="course-tool-link" data-analytics-id="edx.reviews" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/course/reviews">
<span class="icon fa fa-star" aria-hidden="true"></span> <span class="icon fa fa-star" aria-hidden="true"></span>
Reviews Reviews
</a> </a>
</li> </li>
<li> <li>
<a class="course-tool-link" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/course/updates"> <a class="course-tool-link" data-analytics-id="edx.updates" href="/courses/course-v1:W3Cx+HTML5.0x+1T2017/course/updates">
<span class="icon fa fa-newspaper-o" aria-hidden="true"></span> <span class="icon fa fa-newspaper-o" aria-hidden="true"></span>
Updates Updates
</a> </a>
......
...@@ -4,13 +4,12 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export ...@@ -4,13 +4,12 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export
constructor(options) { constructor(options) {
// Logging for course tool click events // Logging for course tool click events
const $courseToolLink = $(options.courseToolLink); const $courseToolLink = $(options.courseToolLink);
$courseToolLink.on('click', () => { $courseToolLink.on('click', (event) => {
const courseToolName = document.querySelector('.course-tool-link').text.trim().toLowerCase(); const courseToolName = event.srcElement.dataset['analytics-id']; // eslint-disable-line dot-notation
Logger.log( Logger.log(
'edx.course.tool.accessed', 'edx.course.tool.accessed',
{ {
tool_name: courseToolName, tool_name: courseToolName,
page: 'course_home',
}, },
); );
}); });
......
...@@ -15,15 +15,19 @@ describe('Course Home factory', () => { ...@@ -15,15 +15,19 @@ describe('Course Home factory', () => {
}); });
it('sends an event when an course tool is clicked', () => { it('sends an event when an course tool is clicked', () => {
document.querySelector('.course-tool-link').dispatchEvent(new Event('click')); const courseToolNames = document.querySelectorAll('.course-tool-link');
const courseToolName = document.querySelector('.course-tool-link').text.trim().toLowerCase(); for (let i = 0; i < courseToolNames.length; i += 1) {
expect(Logger.log).toHaveBeenCalledWith( const courseToolName = courseToolNames[i].dataset['analytics-id']; // eslint-disable-line dot-notation
'edx.course.tool.accessed', const event = new CustomEvent('click');
{ event.srcElement = { dataset: { 'analytics-id': courseToolName } };
tool_name: courseToolName, courseToolNames[i].dispatchEvent(event);
page: 'course_home', expect(Logger.log).toHaveBeenCalledWith(
}, 'edx.course.tool.accessed',
); {
tool_name: courseToolName,
},
);
}
}); });
}); });
}); });
...@@ -57,6 +57,10 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV ...@@ -57,6 +57,10 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
<div class="page-content"> <div class="page-content">
<div class="layout layout-1t2t"> <div class="layout layout-1t2t">
<main class="layout-col layout-col-b"> <main class="layout-col layout-col-b">
% 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): % if welcome_message_fragment and UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id):
<div class="section section-dates"> <div class="section section-dates">
${HTML(welcome_message_fragment.body_html())} ${HTML(welcome_message_fragment.body_html())}
...@@ -74,7 +78,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV ...@@ -74,7 +78,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
<ul class="list-unstyled"> <ul class="list-unstyled">
% for course_tool in course_tools: % for course_tool in course_tools:
<li> <li>
<a class="course-tool-link" href="${course_tool.url(course_key)}"> <a class="course-tool-link" data-analytics-id="${course_tool.analytics_id()}" href="${course_tool.url(course_key)}">
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span> <span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>
${course_tool.title()} ${course_tool.title()}
</a> </a>
......
## 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:
<div class="course-message grid-manual">
% if not is_rtl:
<img class="message-author col col-2" src="${static.url(image_src)}"/>
% endif
<div class="message-content col col-9">
${HTML(message.message_html)}
</div>
% if is_rtl:
<img class="message-author col col-2" src="${static.url(image_src)}"/>
% endif
</div>
% endfor
% endif
...@@ -2,10 +2,10 @@ ...@@ -2,10 +2,10 @@
""" """
Tests for the course home page. Tests for the course home page.
""" """
import datetime from datetime import datetime, timedelta
import ddt import ddt
import mock import mock
import pytz from pytz import UTC
from waffle.testutils import override_flag from waffle.testutils import override_flag
from courseware.tests.factories import StaffFactory from courseware.tests.factories import StaffFactory
...@@ -31,6 +31,10 @@ TEST_CHAPTER_NAME = 'Test Chapter' ...@@ -31,6 +31,10 @@ TEST_CHAPTER_NAME = 'Test Chapter'
TEST_WELCOME_MESSAGE = '<h2>Welcome!</h2>' TEST_WELCOME_MESSAGE = '<h2>Welcome!</h2>'
TEST_UPDATE_MESSAGE = '<h2>Test Update!</h2>' TEST_UPDATE_MESSAGE = '<h2>Test Update!</h2>'
TEST_COURSE_UPDATES_TOOL = '/course/updates">' 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 QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES
...@@ -73,7 +77,12 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase): ...@@ -73,7 +77,12 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
# pylint: disable=super-method-not-called # pylint: disable=super-method-not-called
with super(CourseHomePageTestCase, cls).setUpClassAndTestData(): with super(CourseHomePageTestCase, cls).setUpClassAndTestData():
with cls.store.default_store(ModuleStoreEnum.Type.split): 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): with cls.store.bulk_operations(cls.course.id):
chapter = ItemFactory.create( chapter = ItemFactory.create(
category='chapter', category='chapter',
...@@ -92,6 +101,15 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase): ...@@ -92,6 +101,15 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
cls.user = UserFactory(password=TEST_PASSWORD) cls.user = UserFactory(password=TEST_PASSWORD)
CourseEnrollment.enroll(cls.user, cls.course.id) 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): class TestCourseHomePage(CourseHomePageTestCase):
def setUp(self): def setUp(self):
...@@ -142,7 +160,7 @@ class TestCourseHomePage(CourseHomePageTestCase): ...@@ -142,7 +160,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course) course_home_url(self.course)
# Fetch the view and verify the query counts # Fetch the view and verify the query counts
with self.assertNumQueries(38, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with self.assertNumQueries(37, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4): with check_mongo_calls(4):
url = course_home_url(self.course) url = course_home_url(self.course)
self.client.get(url) self.client.get(url)
...@@ -152,18 +170,15 @@ class TestCourseHomePage(CourseHomePageTestCase): ...@@ -152,18 +170,15 @@ class TestCourseHomePage(CourseHomePageTestCase):
""" """
Verify that the course home page handles start dates correctly. 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 # 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) response = self.client.get(url)
self.assertRedirects(response, '/dashboard?notlive=Jan+01%2C+2030') self.assertRedirects(response, '/dashboard?notlive=Jan+01%2C+2030')
# With the Waffle flag enabled, the course should be visible # With the Waffle flag enabled, the course should be visible
with override_flag(COURSE_PRE_START_ACCESS_FLAG.namespaced_flag_name, True): 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) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -272,11 +287,12 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): ...@@ -272,11 +287,12 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
Ensure that a user accessing a non-live course sees a redirect to Ensure that a user accessing a non-live course sees a redirect to
the student dashboard, not a 404. 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) 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 = QueryDict(mutable=True)
expected_params['notlive'] = start_date expected_params['notlive'] = start_date
expected_url = '{url}?{params}'.format( expected_url = '{url}?{params}'.format(
...@@ -292,12 +308,13 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): ...@@ -292,12 +308,13 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
Ensure that a user accessing a non-live course sees a redirect to 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 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îµé" fake_unicode_start_time = u"üñîçø∂é_ßtå®t_tîµé"
mock_strftime_localized.return_value = fake_unicode_start_time 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) response = self.client.get(url)
expected_params = QueryDict(mutable=True) expected_params = QueryDict(mutable=True)
expected_params['notlive'] = fake_unicode_start_time expected_params['notlive'] = fake_unicode_start_time
...@@ -316,3 +333,44 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): ...@@ -316,3 +333,44 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
url = course_home_url_from_string('not/a/course') url = course_home_url_from_string('not/a/course')
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 404) 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)
...@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase): ...@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course) course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed # Fetch the view and verify that the query counts haven't changed
with self.assertNumQueries(31, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with self.assertNumQueries(30, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4): with check_mongo_calls(4):
url = course_updates_url(self.course) url = course_updates_url(self.course)
self.client.get(url) self.client.get(url)
...@@ -26,6 +26,7 @@ from web_fragments.fragment import Fragment ...@@ -26,6 +26,7 @@ from web_fragments.fragment import Fragment
from ..utils import get_course_outline_block_tree from ..utils import get_course_outline_block_tree
from .course_dates import CourseDatesFragmentView from .course_dates import CourseDatesFragmentView
from .course_home_messages import CourseHomeMessageFragmentView
from .course_outline import CourseOutlineFragmentView from .course_outline import CourseOutlineFragmentView
from .course_sock import CourseSockFragmentView from .course_sock import CourseSockFragmentView
from .welcome_message import WelcomeMessageFragmentView from .welcome_message import WelcomeMessageFragmentView
...@@ -113,9 +114,12 @@ class CourseHomeFragmentView(EdxFragmentView): ...@@ -113,9 +114,12 @@ class CourseHomeFragmentView(EdxFragmentView):
# Render the full content to enrolled users, as well as to course and global staff. # 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. # Unenrolled users who are not course or global staff are given only a subset.
is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key) user_access = {
is_staff = has_access(request.user, 'staff', course_key) 'is_anonymous': request.user.is_anonymous(),
if is_enrolled or is_staff: '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) outline_fragment = CourseOutlineFragmentView().render_to_fragment(request, course_id=course_id, **kwargs)
welcome_message_fragment = WelcomeMessageFragmentView().render_to_fragment( welcome_message_fragment = WelcomeMessageFragmentView().render_to_fragment(
request, course_id=course_id, **kwargs request, course_id=course_id, **kwargs
...@@ -141,6 +145,11 @@ class CourseHomeFragmentView(EdxFragmentView): ...@@ -141,6 +145,11 @@ class CourseHomeFragmentView(EdxFragmentView):
# Get the course tools enabled for this user and course # Get the course tools enabled for this user and course
course_tools = CourseToolsPluginManager.get_enabled_course_tools(request, course_key) 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 # Render the course home fragment
context = { context = {
'request': request, 'request': request,
...@@ -149,6 +158,7 @@ class CourseHomeFragmentView(EdxFragmentView): ...@@ -149,6 +158,7 @@ class CourseHomeFragmentView(EdxFragmentView):
'course_key': course_key, 'course_key': course_key,
'outline_fragment': outline_fragment, 'outline_fragment': outline_fragment,
'handouts_html': handouts_html, 'handouts_html': handouts_html,
'course_home_message_fragment': course_home_message_fragment,
'has_visited_course': has_visited_course, 'has_visited_course': has_visited_course,
'resume_course_url': resume_course_url, 'resume_course_url': resume_course_url,
'course_tools': course_tools, 'course_tools': course_tools,
......
"""
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("<a href='/login?next={current_url}'>{sign_in_label}</a>").format(
sign_in_label=_("Sign in"),
current_url=urlquote_plus(request.path),
),
register_link=HTML("<a href='/register?next={current_url}'>{register_label}</a>").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']
)
)
...@@ -26,7 +26,6 @@ from openedx.core.lib.token_utils import JwtBuilder ...@@ -26,7 +26,6 @@ from openedx.core.lib.token_utils import JwtBuilder
try: try:
from enterprise import utils as enterprise_utils from enterprise import utils as enterprise_utils
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomer from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomer
from enterprise.tpa_pipeline import get_enterprise_customer_for_request
from enterprise.utils import consent_necessary_for_course from enterprise.utils import consent_necessary_for_course
except ImportError: except ImportError:
pass pass
...@@ -240,9 +239,9 @@ def enterprise_customer_for_request(request, tpa_hint=None): ...@@ -240,9 +239,9 @@ def enterprise_customer_for_request(request, tpa_hint=None):
if not enterprise_enabled(): if not enterprise_enabled():
return None return None
ec = get_enterprise_customer_for_request(request) ec = None
if not ec and tpa_hint: if tpa_hint:
try: try:
ec = EnterpriseCustomer.objects.get(enterprise_customer_identity_provider__provider_id=tpa_hint) ec = EnterpriseCustomer.objects.get(enterprise_customer_identity_provider__provider_id=tpa_hint)
except EnterpriseCustomer.DoesNotExist: except EnterpriseCustomer.DoesNotExist:
...@@ -308,24 +307,6 @@ def get_enterprise_consent_url(request, course_id, user=None, return_to=None): ...@@ -308,24 +307,6 @@ def get_enterprise_consent_url(request, course_id, user=None, return_to=None):
return full_url return full_url
def insert_enterprise_pipeline_elements(pipeline):
"""
If the enterprise app is enabled, insert additional elements into the
pipeline so that data sharing consent views are used.
"""
if not enterprise_enabled():
return
additional_elements = (
'enterprise.tpa_pipeline.handle_enterprise_logistration',
)
# Find the item we need to insert the data sharing consent elements before
insert_point = pipeline.index('social_core.pipeline.social_auth.load_extra_data')
for index, element in enumerate(additional_elements):
pipeline.insert(insert_point + index, element)
def get_cache_key(**kwargs): def get_cache_key(**kwargs):
""" """
Get MD5 encoded cache key for given arguments. Get MD5 encoded cache key for given arguments.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment