Merge remote-tracking branch 'origin/master' into EDUCATOR-926
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -131,3 +131,6 @@ lms/lib/comment_client/python
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
dist
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode
|
||||
|
||||
@@ -318,7 +318,7 @@ class TestCourseListing(ModuleStoreTestCase, XssTestMixin):
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.split, 3, 13),
|
||||
(ModuleStoreEnum.Type.split, 4, 23),
|
||||
(ModuleStoreEnum.Type.mongo, USER_COURSES_COUNT, 2)
|
||||
)
|
||||
@ddt.unpack
|
||||
|
||||
@@ -1547,22 +1547,27 @@ def group_configurations_list_handler(request, course_key_string):
|
||||
|
||||
all_partitions = GroupConfiguration.get_all_user_partition_details(store, course)
|
||||
should_show_enrollment_track = False
|
||||
group_schemes = []
|
||||
has_content_groups = False
|
||||
displayable_partitions = []
|
||||
for partition in all_partitions:
|
||||
group_schemes.append(partition['scheme'])
|
||||
if partition['scheme'] == ENROLLMENT_SCHEME:
|
||||
enrollment_track_configuration = partition
|
||||
should_show_enrollment_track = len(enrollment_track_configuration['groups']) > 1
|
||||
if partition['scheme'] == COHORT_SCHEME:
|
||||
has_content_groups = True
|
||||
displayable_partitions.append(partition)
|
||||
elif partition['scheme'] == ENROLLMENT_SCHEME:
|
||||
should_show_enrollment_track = len(partition['groups']) > 1
|
||||
|
||||
# Remove the enrollment track partition and add it to the front of the list if it should be shown.
|
||||
all_partitions.remove(partition)
|
||||
# Add it to the front of the list if it should be shown.
|
||||
if should_show_enrollment_track:
|
||||
all_partitions.insert(0, partition)
|
||||
displayable_partitions.insert(0, partition)
|
||||
elif partition['scheme'] != RANDOM_SCHEME:
|
||||
# Experiment group configurations are handled explicitly above. We don't
|
||||
# want to display their groups twice.
|
||||
displayable_partitions.append(partition)
|
||||
|
||||
# Add empty content group if there is no COHORT User Partition in the list.
|
||||
# This will add ability to add new groups in the view.
|
||||
if COHORT_SCHEME not in group_schemes:
|
||||
all_partitions.append(GroupConfiguration.get_or_create_content_group(store, course))
|
||||
if not has_content_groups:
|
||||
displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course))
|
||||
|
||||
return render_to_response('group_configurations.html', {
|
||||
'context_course': course,
|
||||
@@ -1570,7 +1575,7 @@ def group_configurations_list_handler(request, course_key_string):
|
||||
'course_outline_url': course_outline_url,
|
||||
'experiment_group_configurations': experiment_group_configurations,
|
||||
'should_show_experiment_groups': should_show_experiment_groups,
|
||||
'all_group_configurations': all_partitions,
|
||||
'all_group_configurations': displayable_partitions,
|
||||
'should_show_enrollment_track': should_show_enrollment_track
|
||||
})
|
||||
elif "application/json" in request.META.get('HTTP_ACCEPT'):
|
||||
|
||||
@@ -250,6 +250,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
|
||||
Basic check that the groups configuration page responds correctly.
|
||||
"""
|
||||
|
||||
# This creates a random UserPartition.
|
||||
self.course.user_partitions = [
|
||||
UserPartition(0, 'First name', 'First description', [Group(0, 'Group A'), Group(1, 'Group B'), Group(2, 'Group C')]),
|
||||
]
|
||||
@@ -261,7 +262,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
|
||||
|
||||
response = self.client.get(self._url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'First name')
|
||||
self.assertContains(response, 'First name', count=1)
|
||||
self.assertContains(response, 'Group C')
|
||||
self.assertContains(response, CONTENT_GROUP_CONFIGURATION_NAME)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_API_KEY": "",
|
||||
"AWS_ACCESS_KEY_ID": "",
|
||||
"AWS_SECRET_ACCESS_KEY": "",
|
||||
"CELERY_BROKER_PASSWORD": "celery",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_SERVER_URL": "",
|
||||
"BOOK_URL": "",
|
||||
"BUGS_EMAIL": "bugs@example.com",
|
||||
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_API_KEY": "",
|
||||
"AWS_ACCESS_KEY_ID": "",
|
||||
"AWS_SECRET_ACCESS_KEY": "",
|
||||
"CELERY_BROKER_PASSWORD": "celery",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_SERVER_URL": "",
|
||||
"BOOK_URL": "",
|
||||
"BUGS_EMAIL": "bugs@example.com",
|
||||
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",
|
||||
|
||||
@@ -710,6 +710,10 @@ base_vendor_js = [
|
||||
'edx-ui-toolkit/js/utils/string-utils.js',
|
||||
'edx-ui-toolkit/js/utils/html-utils.js',
|
||||
|
||||
# Load Bootstrap and supporting libraries
|
||||
'common/js/vendor/tether.js',
|
||||
'common/js/vendor/bootstrap.js',
|
||||
|
||||
# Finally load RequireJS
|
||||
'common/js/vendor/require.js'
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
// +Base - Utilities
|
||||
// ====================
|
||||
@import 'partials/variables';
|
||||
@import 'cms/base/variables';
|
||||
@import 'mixins';
|
||||
@import 'mixins-inherited';
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
// Configuration
|
||||
@import 'config';
|
||||
|
||||
// Extensions
|
||||
@import 'partials/variables';
|
||||
// +Base - Utilities
|
||||
// ====================
|
||||
@import 'partials/cms/base/variables';
|
||||
@import 'mixins-v2';
|
||||
@import 'base-v2';
|
||||
|
||||
// Pattern Library styling
|
||||
@import 'elements-v2/controls';
|
||||
@import 'elements-v2/header';
|
||||
@import 'elements-v2/navigation';
|
||||
|
||||
11
cms/static/sass/bootstrap/_base.scss
Normal file
11
cms/static/sass/bootstrap/_base.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
// Open edX: Studio base styles
|
||||
// ============================
|
||||
//
|
||||
// Note: these styles replicate the Studio styles directly
|
||||
// rather than benefiting from any Bootstrap classes. Ideally
|
||||
// the code base should be rebuilt using Bootstrap and then
|
||||
// these styles will no longer be necessary.
|
||||
|
||||
.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
195
cms/static/sass/bootstrap/_components.scss
Normal file
195
cms/static/sass/bootstrap/_components.scss
Normal file
@@ -0,0 +1,195 @@
|
||||
// Open edX: components
|
||||
// ====================
|
||||
|
||||
// Skip nav
|
||||
.nav-skip,
|
||||
.transcript-skip {
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -($baseline*30);
|
||||
overflow: hidden;
|
||||
background: $white;
|
||||
border-bottom: 1px solid $gray-lightest;
|
||||
padding: ($baseline*0.75) ($baseline/2);
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
position: relative;
|
||||
top: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Page banner
|
||||
.page-banner {
|
||||
max-width: $studio-max-width;
|
||||
margin: 0 auto;
|
||||
|
||||
.user-messages {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts
|
||||
.alert {
|
||||
.icon-alert {
|
||||
margin-right: $baseline / 4;
|
||||
}
|
||||
}
|
||||
|
||||
// Sock
|
||||
.wrapper-sock {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
margin: ($baseline*2) 0 0 0;
|
||||
border-top: 1px solid $gray-light;
|
||||
width: 100%;
|
||||
|
||||
.wrapper-inner {
|
||||
display: none;
|
||||
width: 100% !important;
|
||||
border-bottom: 1px solid $white;
|
||||
padding: 0 $baseline !important;
|
||||
}
|
||||
|
||||
// sock - actions
|
||||
.list-cta {
|
||||
@extend %ui-depth1;
|
||||
position: absolute;
|
||||
top: -($baseline*0.75);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
list-style: none;
|
||||
|
||||
.cta-show-sock {
|
||||
@extend %ui-btn-pill;
|
||||
@extend %t-action4;
|
||||
background: $gray-lightest;
|
||||
padding: ($baseline/2) $baseline;
|
||||
color: $gray-light;
|
||||
|
||||
.icon {
|
||||
margin-right: $baseline/4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $brand-primary;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sock - additional help
|
||||
.sock {
|
||||
@include clearfix();
|
||||
@extend %t-copy-sub2;
|
||||
max-width: $studio-max-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto;
|
||||
padding: ($baseline*2) 0;
|
||||
color: $gray-light;
|
||||
|
||||
// support body
|
||||
header {
|
||||
|
||||
.title {
|
||||
@extend %t-title4;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// shared elements
|
||||
.support, .feedback {
|
||||
.title {
|
||||
@extend %t-title6;
|
||||
color: $white;
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
|
||||
.copy {
|
||||
@extend %t-copy-sub2;
|
||||
margin: 0 0 $baseline 0;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
@include clearfix();
|
||||
|
||||
.action-item {
|
||||
float: left;
|
||||
margin-right: $baseline/2;
|
||||
margin-bottom: ($baseline/2);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action {
|
||||
@extend %t-action4;
|
||||
display: block;
|
||||
|
||||
.icon {
|
||||
@extend %t-icon4;
|
||||
vertical-align: middle;
|
||||
margin-right: $baseline/4;
|
||||
}
|
||||
|
||||
&:hover, &:active {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend %cont-text-sr;
|
||||
}
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@extend %btn-primary-blue;
|
||||
@extend %t-action3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// studio support content
|
||||
.support {
|
||||
width: flex-grid(8,12);
|
||||
float: left;
|
||||
margin-right: flex-gutter();
|
||||
|
||||
.action-item {
|
||||
width: flexgrid(4,8);
|
||||
}
|
||||
}
|
||||
|
||||
// studio feedback content
|
||||
.feedback {
|
||||
width: flex-grid(4,12);
|
||||
float: left;
|
||||
|
||||
.action-item {
|
||||
width: flexgrid(4,4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// case: sock content is shown
|
||||
&.is-shown {
|
||||
border-color: $gray-dark;
|
||||
|
||||
.list-cta .cta-show-sock {
|
||||
background: $gray-dark;
|
||||
border-color: $gray-dark;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
cms/static/sass/bootstrap/_footer.scss
Normal file
96
cms/static/sass/bootstrap/_footer.scss
Normal file
@@ -0,0 +1,96 @@
|
||||
// Open edX: Studio footer
|
||||
// =======================
|
||||
//
|
||||
// Note: these styles replicate the Studio styles directly
|
||||
// rather than benefiting from any Bootstrap classes. Ideally
|
||||
// the header should be reimagined using Bootstrap and then
|
||||
// these styles will no longer be necessary.
|
||||
|
||||
.wrapper-footer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin: 0 0 $baseline 0;
|
||||
padding: $baseline;
|
||||
|
||||
footer.primary {
|
||||
@extend %t-copy-sub2;
|
||||
@include clearfix();
|
||||
max-width: $studio-max-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto;
|
||||
color: $gray-light;
|
||||
|
||||
.footer-content-primary {
|
||||
@include clearfix();
|
||||
}
|
||||
|
||||
.colophon {
|
||||
width: flex-grid(4, 12);
|
||||
float: left;
|
||||
margin-right: flex-gutter(2);
|
||||
}
|
||||
|
||||
.nav-peripheral {
|
||||
width: flex-grid(6, 12);
|
||||
float: right;
|
||||
text-align: right;
|
||||
|
||||
.nav-item {
|
||||
display: inline-block;
|
||||
margin-right: $baseline/4;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
border-radius: 2px;
|
||||
padding: ($baseline/2) ($baseline/2);
|
||||
background: transparent;
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: $baseline/4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-content-secondary {
|
||||
@include clearfix();
|
||||
margin-top: $baseline;
|
||||
}
|
||||
|
||||
.footer-about-copyright, .footer-about-openedx {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// platform trademarks
|
||||
.footer-about-copyright {
|
||||
width: flex-grid(4, 12);
|
||||
float: left;
|
||||
margin-right: flex-gutter(2);
|
||||
}
|
||||
|
||||
// platform Open edX logo and link
|
||||
.footer-about-openedx {
|
||||
float: right;
|
||||
text-align: right;
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: ($baseline* 6);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
568
cms/static/sass/bootstrap/_header.scss
Normal file
568
cms/static/sass/bootstrap/_header.scss
Normal file
@@ -0,0 +1,568 @@
|
||||
// Open edX: Studio header
|
||||
// =======================
|
||||
//
|
||||
// Note: these styles replicate the Studio styles directly
|
||||
// rather than benefiting from any Bootstrap classes. Ideally
|
||||
// the header should be reimagined using Bootstrap and then
|
||||
// these styles will no longer be necessary.
|
||||
|
||||
// studio - elements - global header
|
||||
// ====================
|
||||
|
||||
.wrapper-header {
|
||||
@extend %ui-depth3;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-shadow: 0 1px 2px 0 $shadow-l1;
|
||||
margin: 0;
|
||||
padding: 0 $baseline;
|
||||
background: $white;
|
||||
|
||||
header.primary {
|
||||
@include clearfix();
|
||||
max-width: $studio-max-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// basic layout
|
||||
.wrapper-l, .wrapper-r {
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.wrapper-l {
|
||||
float: left;
|
||||
width: flex-grid(7,12);
|
||||
}
|
||||
|
||||
.wrapper-r {
|
||||
float: right;
|
||||
width: flex-grid(4,12);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.branding, .info-course, .nav-course, .nav-account, .nav-pitch {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-language-selector {
|
||||
width: 120px;
|
||||
display: inline-block;
|
||||
margin: 0 10px 0 5px;
|
||||
vertical-align: sub;
|
||||
|
||||
.language-selector {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-account {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
// basic layout - nav items
|
||||
nav {
|
||||
|
||||
> ol > .nav-item {
|
||||
@extend %t-action3;
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item a {
|
||||
color: $gray;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basic layout - dropdowns
|
||||
.nav-dd {
|
||||
|
||||
.title {
|
||||
@extend %t-action2;
|
||||
@extend %ui-btn-dd-nav-primary;
|
||||
|
||||
.label, .fa-caret-down {
|
||||
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
}
|
||||
|
||||
.fa-caret-down {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
.fa-caret-down {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-sub .nav-item {
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// specific elements - branding
|
||||
.branding {
|
||||
padding: ($baseline*0.75) 0;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
|
||||
img {
|
||||
max-height: ($baseline*2);
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// specific elements - course name/info
|
||||
.info-course {
|
||||
margin-right: flex-gutter();
|
||||
border-right: 1px solid $gray-lighter;
|
||||
padding: ($baseline*0.75) flex-gutter() ($baseline*0.75) 0;
|
||||
|
||||
.course-org, .course-number {
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 1.0;
|
||||
color: $gray-dark;
|
||||
}
|
||||
|
||||
.course-org {
|
||||
margin-right: $baseline/4;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// entire link
|
||||
.course-link {
|
||||
display: block;
|
||||
color: $gray-dark;
|
||||
|
||||
&:hover {
|
||||
color: $link-hover-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// specific elements - course nav
|
||||
.nav-course {
|
||||
padding: ($baseline*0.75) 0;
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// specific elements - account-based nav
|
||||
.nav-account {
|
||||
position: relative;
|
||||
padding: ($baseline*0.75) 0;
|
||||
|
||||
.nav-sub {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-account-help {
|
||||
|
||||
.wrapper-nav-sub {
|
||||
width: ($baseline*10);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-account-user {
|
||||
|
||||
.title {
|
||||
max-width: ($baseline*6.5);
|
||||
|
||||
> .label {
|
||||
display: inline-block;
|
||||
max-width: 84%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-language-form {
|
||||
margin-top: 4px;
|
||||
.language-selector {
|
||||
width: 130px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// specific elements - pitch/how it works nav
|
||||
.nav-pitch {
|
||||
position: relative;
|
||||
padding: ($baseline*0.75) 0;
|
||||
|
||||
.nav-sub {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// CASE: user signed in
|
||||
.is-signedin {
|
||||
|
||||
.wrapper-l {
|
||||
width: flex-grid(8,12);
|
||||
}
|
||||
|
||||
.wrapper-r {
|
||||
width: flex-grid(4,12);
|
||||
}
|
||||
|
||||
.branding {
|
||||
margin-right: 2%;
|
||||
}
|
||||
|
||||
.nav-account {
|
||||
top: ($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// CASE: in course {
|
||||
.is-signedin.course {
|
||||
|
||||
.wrapper-header {
|
||||
|
||||
.wrapper-l {
|
||||
width: flex-grid(9,12);
|
||||
}
|
||||
|
||||
.wrapper-r {
|
||||
width: flex-grid(3,12);
|
||||
}
|
||||
|
||||
.branding {
|
||||
margin-right: 2%;
|
||||
}
|
||||
|
||||
.info-course {
|
||||
width: 25%;
|
||||
margin-right: 2%;
|
||||
}
|
||||
|
||||
.nav-course {
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// CASE: user not signed in
|
||||
.not-signedin,
|
||||
.view-util {
|
||||
|
||||
.wrapper-header {
|
||||
|
||||
.wrapper-l {
|
||||
width: flex-grid(2,12);
|
||||
}
|
||||
|
||||
.wrapper-r {
|
||||
width: flex-grid(10,12);
|
||||
}
|
||||
|
||||
.branding {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-pitch {
|
||||
top: ($baseline/4);
|
||||
|
||||
.nav-item {
|
||||
margin-right: ($baseline/2);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-signup {
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-signin {
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropdown
|
||||
.nav-dd {
|
||||
|
||||
.title {
|
||||
|
||||
.label, .fa-caret-down {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ui-toggle-dd {
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// dropped down state
|
||||
&.is-selected {
|
||||
|
||||
.ui-toggle-dd {
|
||||
transform: rotate(-180deg);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
|
||||
}
|
||||
|
||||
&.nav-course-settings {
|
||||
.wrapper-nav-sub {
|
||||
width: ($baseline*9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-nav-sub {
|
||||
position: absolute;
|
||||
top: ($baseline*2.5);
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
width: ($baseline*8);
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
|
||||
|
||||
// dropped down state
|
||||
&.is-shown {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-sub {
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 1px $shadow-l1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 1px solid $gray-light;
|
||||
padding: ($baseline/2) ($baseline*0.75);
|
||||
background: $white;
|
||||
|
||||
&:after, &:before {
|
||||
bottom: 100%;
|
||||
border: solid transparent;
|
||||
content: " ";
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// ui triangle/nub
|
||||
&:after {
|
||||
border-color: rgba(255, 255, 255, 0);
|
||||
border-bottom-color: $white;
|
||||
border-width: 10px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
border-color: rgba(178, 178, 178, 0);
|
||||
border-bottom-color: $gray-light;
|
||||
border-width: 11px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@extend %t-action3;
|
||||
@extend %t-regular;
|
||||
display: block;
|
||||
margin: 0 0 ($baseline/4) 0;
|
||||
border-bottom: 1px solid $gray-lighter;
|
||||
padding: 0 0($baseline/4) 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: block;
|
||||
|
||||
&:hover, &:active {
|
||||
color: $brand-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: left-hand side arrow/dd
|
||||
&.ui-left {
|
||||
|
||||
.wrapper-nav-sub {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.nav-sub {
|
||||
text-align: left;
|
||||
|
||||
// ui triangle/nub
|
||||
&:after {
|
||||
left: $baseline;
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
left: $baseline;
|
||||
margin-left: -11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: right-hand side arrow/dd
|
||||
&.ui-right {
|
||||
|
||||
.wrapper-nav-sub {
|
||||
left: none;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-sub {
|
||||
|
||||
// ui triangle/nub
|
||||
&:after {
|
||||
right: $baseline;
|
||||
margin-right: -10px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
right: $baseline;
|
||||
margin-right: -11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// STATE: active/current nav states
|
||||
|
||||
.nav-item.is-current,
|
||||
body.howitworks .nav-not-signedin-hiw,
|
||||
|
||||
// dashboard
|
||||
body.dashboard .nav-account-dashboard,
|
||||
|
||||
// course content
|
||||
body.course.view-outline .nav-course-courseware .title,
|
||||
body.course.view-updates .nav-course-courseware .title,
|
||||
body.course.view-static-pages .nav-course-courseware .title,
|
||||
body.course.view-uploads .nav-course-courseware .title,
|
||||
body.course.view-textbooks .nav-course-courseware .title,
|
||||
body.course.view-video-uploads .nav-course-courseware .title,
|
||||
|
||||
body.course.view-outline .nav-course-courseware-outline,
|
||||
body.course.view-updates .nav-course-courseware-updates,
|
||||
body.course.view-static-pages .nav-course-courseware-pages,
|
||||
body.course.view-uploads .nav-course-courseware-uploads,
|
||||
body.course.view-textbooks .nav-course-courseware-textbooks,
|
||||
body.course.view-video-uploads .nav-course-courseware-videos,
|
||||
|
||||
// course settings
|
||||
body.course.schedule .nav-course-settings .title,
|
||||
body.course.grading .nav-course-settings .title,
|
||||
body.course.view-team .nav-course-settings .title,
|
||||
body.course.view-group-configurations .nav-course-settings .title,
|
||||
body.course.advanced .nav-course-settings .title,
|
||||
body.course.view-certificates .nav-course-settings .title,
|
||||
|
||||
body.course.schedule .nav-course-settings-schedule,
|
||||
body.course.grading .nav-course-settings-grading,
|
||||
body.course.view-team .nav-course-settings-team,
|
||||
body.course.view-group-configurations .nav-course-settings-group-configurations,
|
||||
body.course.advanced .nav-course-settings-advanced,
|
||||
body.course.view-certificates .nav-course-settings-certificates,
|
||||
|
||||
// course tools
|
||||
body.course.view-import .nav-course-tools .title,
|
||||
body.course.view-export .nav-course-tools .title,
|
||||
body.course.view-export-git .nav-course-tools .title,
|
||||
|
||||
body.course.view-import .nav-course-tools-import,
|
||||
body.course.view-export .nav-course-tools-export,
|
||||
body.course.view-export-git .nav-course-tools-export-git,
|
||||
|
||||
// content library settings
|
||||
body.course.view-team .nav-library-settings .title,
|
||||
|
||||
body.course.view-team .nav-library-settings-team
|
||||
|
||||
{
|
||||
color: $brand-primary;
|
||||
|
||||
a {
|
||||
color: $brand-primary;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
362
cms/static/sass/bootstrap/_layouts.scss
Normal file
362
cms/static/sass/bootstrap/_layouts.scss
Normal file
@@ -0,0 +1,362 @@
|
||||
// Open edX: Studio layout
|
||||
// =======================
|
||||
//
|
||||
// Note: these styles replicate the Studio styles directly
|
||||
// rather than benefiting from any Bootstrap classes. Ideally
|
||||
// the layouts should be reimagined using Bootstrap and then
|
||||
// these styles will no longer be necessary.
|
||||
|
||||
.content-wrapper {
|
||||
margin-top: $baseline;
|
||||
|
||||
.course-tabs {
|
||||
padding-bottom: 0;
|
||||
|
||||
.nav-item {
|
||||
&.active, &:hover{
|
||||
.nav-link {
|
||||
border-bottom-color: $brand-primary;
|
||||
color: $brand-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: $baseline/2 $baseline*3/4 $baseline*13/20;
|
||||
border-style: solid;
|
||||
border-width: 0 0 $baseline/5 0;
|
||||
border-bottom-color: transparent;
|
||||
|
||||
@media (max-width: map-get($grid-breakpoints, md)) {
|
||||
border: none;
|
||||
text-align: left;
|
||||
padding: 0 0 $baseline/2 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-container {
|
||||
border: 1px solid $inverse-color;
|
||||
background-color: $body-bg;
|
||||
|
||||
.page-header {
|
||||
border-bottom: 1px solid $inverse-color;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&.container-fluid {
|
||||
max-width: $studio-max-width;
|
||||
}
|
||||
}
|
||||
|
||||
// studio - elements - layouts
|
||||
// ====================
|
||||
|
||||
// layout - basic
|
||||
// the wrapper around the viewable page area, excluding modal and other extra-view content
|
||||
.wrapper-view {
|
||||
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// layout - basic page header
|
||||
.wrapper-mast {
|
||||
margin: ($baseline*1.5) 0 0 0;
|
||||
padding: 0 $baseline;
|
||||
position: relative;
|
||||
|
||||
.mast,
|
||||
.metadata {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
max-width: $studio-max-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto $baseline auto;
|
||||
color: $gray-dark;
|
||||
}
|
||||
|
||||
.mast {
|
||||
border-bottom: 1px solid $gray-light;
|
||||
padding-bottom: ($baseline/2);
|
||||
|
||||
// layout without actions
|
||||
.page-header {
|
||||
width: flex-grid(12);
|
||||
}
|
||||
|
||||
// layout with actions
|
||||
&.has-actions {
|
||||
@include clearfix();
|
||||
|
||||
.page-header {
|
||||
float: left;
|
||||
width: flex-grid(6,12);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
position: relative;
|
||||
bottom: -($baseline*0.75);
|
||||
float: right;
|
||||
width: flex-grid(6,12);
|
||||
text-align: right;
|
||||
|
||||
.nav-item {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: ($baseline/2);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// buttons
|
||||
.button {
|
||||
@extend %btn-primary-blue;
|
||||
@extend %sizing;
|
||||
|
||||
.action-button-text {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
// CASE: new/create button
|
||||
&.new-button,
|
||||
&.button-new {
|
||||
@extend %btn-primary-green;
|
||||
@extend %sizing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// layout with actions
|
||||
&.has-subtitle {
|
||||
|
||||
.nav-actions {
|
||||
bottom: -($baseline*1.5);
|
||||
}
|
||||
}
|
||||
|
||||
// layout with breadcrumb navigation
|
||||
&.has-navigation {
|
||||
|
||||
.nav-actions {
|
||||
bottom: -($baseline*1.5);
|
||||
}
|
||||
|
||||
.navigation-item {
|
||||
@extend %cont-truncated;
|
||||
display: inline-block;
|
||||
vertical-align: bottom; // correct for extra padding in FF
|
||||
max-width: 250px;
|
||||
color: $gray-dark;
|
||||
|
||||
&.navigation-current {
|
||||
@extend %ui-disabled;
|
||||
color: $gray;
|
||||
max-width: 250px;
|
||||
|
||||
&:before {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-link:hover {
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.navigation-item:before {
|
||||
content: " / ";
|
||||
margin: ($baseline/4);
|
||||
color: $gray;
|
||||
|
||||
&:hover {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation .navigation-item:first-child:before {
|
||||
content: "";
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: wizard-based mast
|
||||
.mast-wizard {
|
||||
|
||||
.page-header-sub {
|
||||
@extend %t-title4;
|
||||
color: $gray;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.page-header-super {
|
||||
@extend %t-title4;
|
||||
float: left;
|
||||
width: flex-grid(12,12);
|
||||
margin-top: ($baseline/2);
|
||||
border-top: 1px solid $gray-lighter;
|
||||
padding-top: ($baseline/2);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// page metadata/action bar
|
||||
.metadata {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// layout - basic page content
|
||||
.wrapper-content {
|
||||
margin: 0;
|
||||
padding: 0 $baseline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
@include clearfix();
|
||||
@extend %t-copy-base;
|
||||
max-width: $studio-max-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto;
|
||||
color: $gray-dark;
|
||||
|
||||
header {
|
||||
position: relative;
|
||||
margin-bottom: $baseline;
|
||||
border-bottom: 1px solid $gray-lighter;
|
||||
padding-bottom: ($baseline/2);
|
||||
|
||||
.title-sub {
|
||||
@extend %t-copy-sub1;
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: $gray-light;
|
||||
}
|
||||
|
||||
.title-1 {
|
||||
@extend %t-title3;
|
||||
@extend %t-strong;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: $gray-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3/4 - 1/4 two col layout
|
||||
%two-col-1 {
|
||||
.content-primary {
|
||||
float: left;
|
||||
margin-right: flex-gutter();
|
||||
width: flex-grid(9,12);
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
float: left;
|
||||
width: flex-grid(3,12);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// layout - primary content
|
||||
.content-primary {
|
||||
|
||||
.title-1 {
|
||||
@extend %t-title3;
|
||||
}
|
||||
|
||||
.title-2 {
|
||||
@extend %t-title4;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
}
|
||||
|
||||
.title-3 {
|
||||
@extend %t-title6;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
}
|
||||
|
||||
header {
|
||||
@include clearfix();
|
||||
|
||||
.title-2 {
|
||||
width: flex-grid(5, 12);
|
||||
margin: 0 flex-gutter() 0 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend %t-copy-sub2;
|
||||
width: flex-grid(7, 12);
|
||||
float: right;
|
||||
margin-top: ($baseline/2);
|
||||
text-align: right;
|
||||
color: $gray-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// layout - supplemental content
|
||||
.content-supplementary {
|
||||
|
||||
> section {
|
||||
margin: 0 0 $baseline 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// layout - grandfathered
|
||||
.main-wrapper {
|
||||
position: relative;
|
||||
margin: 0 ($baseline*2);
|
||||
}
|
||||
|
||||
.inner-wrapper {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
max-width: 1280px;
|
||||
margin: auto;
|
||||
|
||||
> article {
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
.main-column {
|
||||
clear: both;
|
||||
float: left;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
float: right;
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.right {
|
||||
float: right;
|
||||
}
|
||||
239
cms/static/sass/bootstrap/_mixins.scss
Normal file
239
cms/static/sass/bootstrap/_mixins.scss
Normal file
@@ -0,0 +1,239 @@
|
||||
// common - utilities - mixins and extends
|
||||
// ====================
|
||||
//
|
||||
// Note: these mixins replicate the Studio mixins directly
|
||||
// to simplify the usage of Studio Sass partials. They
|
||||
// should be deprecated in favor of using native Bootstrap
|
||||
// functionality.
|
||||
|
||||
|
||||
// Table of Contents
|
||||
// * +Font Sizing - Mixin
|
||||
// * +Line Height - Mixin
|
||||
// * +Sizing - Mixin
|
||||
// * +Square - Mixin
|
||||
// * +Placeholder Styling - Mixin
|
||||
// * +Flex Support - Mixin
|
||||
// * +Flex Polyfill - Extends
|
||||
// * +UI - Wrapper - Extends
|
||||
// * +UI - Window - Extend
|
||||
// * +UI - Visual Link - Extend
|
||||
// * +UI - Functional Disable - Extend
|
||||
// * +UI - Visual Link - Extend
|
||||
// * +UI - Depth Levels - Extends
|
||||
// * +UI - Clear Children - Extends
|
||||
// * +UI - Buttons - Extends
|
||||
// * +UI - Well Archetype - Extends
|
||||
// * +Content - No List - Extends
|
||||
// * +Content - Hidden Image Text - Extend
|
||||
// * +Content - Screenreader Text - Extend
|
||||
// * +Content - Text Wrap - Extend
|
||||
// * +Content - Text Truncate - Extend
|
||||
// * +Icon - Font-Awesome - Extend
|
||||
// * +Icon - SSO icon images
|
||||
|
||||
// +Font Sizing - Mixin
|
||||
// ====================
|
||||
@mixin font-size($sizeValue: 16){
|
||||
font-size: $sizeValue + px;
|
||||
font-size: ($sizeValue/10) + rem;
|
||||
}
|
||||
|
||||
// +Line Height - Mixin
|
||||
// ====================
|
||||
@mixin line-height($fontSize: auto){
|
||||
line-height: ($fontSize*1.48) + px;
|
||||
line-height: (($fontSize/10)*1.48) + rem;
|
||||
}
|
||||
|
||||
// +Sizing - Mixin
|
||||
// ====================
|
||||
@mixin size($width: $baseline, $height: $baseline) {
|
||||
height: $height;
|
||||
width: $width;
|
||||
}
|
||||
|
||||
// +Square - Mixin
|
||||
// ====================
|
||||
@mixin square($size: $baseline) {
|
||||
@include size($size);
|
||||
}
|
||||
|
||||
// +Placeholder Styling - Mixin
|
||||
// ====================
|
||||
@mixin placeholder($color) {
|
||||
:-moz-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
::-webkit-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
:-ms-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
}
|
||||
|
||||
// +Flex Support - Mixin
|
||||
// ====================
|
||||
@mixin ui-flexbox() {
|
||||
display: -webkit-box;
|
||||
display: -moz-box;
|
||||
display: -ms-flexbox;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// +Flex PolyFill - Extends
|
||||
// ====================
|
||||
|
||||
// justify-content right for display:flex alignment in older browsers
|
||||
%ui-justify-right-flex {
|
||||
-webkit-box-pack: flex-end;
|
||||
-moz-box-pack: flex-end;
|
||||
-ms-flex-pack: flex-end;
|
||||
-webkit-justify-content: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// justify-content left for display:flex alignment in older browsers
|
||||
%ui-justify-left-flex {
|
||||
-webkit-box-pack: flex-start;
|
||||
-moz-box-pack: flex-start;
|
||||
-ms-flex-pack: flex-start;
|
||||
-webkit-justify-content: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// align items center for display:flex alignment in older browsers
|
||||
%ui-align-center-flex {
|
||||
-webkit-flex-align: center;
|
||||
-ms-flex-align: center;
|
||||
-webkit-align-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
// +UI - Wrapper - Extends
|
||||
// ====================
|
||||
// used for page/view-level wrappers (for centering/grids)
|
||||
%ui-wrapper {
|
||||
@include clearfix();
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// +UI - Depth Levels - Extends
|
||||
// ====================
|
||||
%ui-depth0 { z-index: 0; }
|
||||
%ui-depth1 { z-index: 10; }
|
||||
%ui-depth2 { z-index: 100; }
|
||||
%ui-depth3 { z-index: 1000; }
|
||||
%ui-depth4 { z-index: 10000; }
|
||||
%ui-depth5 { z-index: 100000; }
|
||||
|
||||
|
||||
// +UI - Clear Children - Extends
|
||||
// ====================
|
||||
// extends - UI - utility - first child clearing
|
||||
%wipe-first-child {
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// extends - UI - utility - last child clearing
|
||||
%wipe-last-child {
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// +Content - No List - Extends
|
||||
// ====================
|
||||
// removes list styling/spacing when using uls, ols for navigation and less content-centric cases
|
||||
%cont-no-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-indent: 0;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// +Content - Hidden Image Text - Extend
|
||||
// ====================
|
||||
// image-replacement hidden text
|
||||
%cont-text-hide {
|
||||
text-indent: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// +Content - Screenreader Text - Extend
|
||||
// ====================
|
||||
%cont-text-sr {
|
||||
// clip has been deprecated but is still supported
|
||||
clip: rect(1px 1px 1px 1px);
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
position: absolute;
|
||||
margin: -1px;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
// ensure there are spaces in sr text
|
||||
word-wrap: normal;
|
||||
}
|
||||
|
||||
// +Content - Text Wrap - Extend
|
||||
// ====================
|
||||
%cont-text-wrap {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
// +Content - Text Truncate - Extend
|
||||
// ====================
|
||||
%cont-truncated {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// * +Icon - Font-Awesome - Extend
|
||||
// ====================
|
||||
%use-font-awesome {
|
||||
display: inline-block;
|
||||
font-family: FontAwesome;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
speak: none;
|
||||
}
|
||||
|
||||
%btn-no-style {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
// * +Icon - SSO icon images
|
||||
// ====================
|
||||
|
||||
%sso-icon {
|
||||
.icon-image {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 1.4em;
|
||||
max-width: 1.4em;
|
||||
margin-top: -2px;
|
||||
}
|
||||
}
|
||||
95
cms/static/sass/bootstrap/_navigation.scss
Normal file
95
cms/static/sass/bootstrap/_navigation.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
// Local overrides for bootstrap navigation bar theming
|
||||
.navigation-container {
|
||||
border-bottom: 2px solid $brand-primary;
|
||||
text-decoration: none;
|
||||
background-color: $header-bg;
|
||||
|
||||
&.slim {
|
||||
border-bottom: 1px solid $inverse-color;
|
||||
box-shadow: 0 1px 5px 0 $black-t0;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0 auto;
|
||||
max-width: map-get($container-max-widths, xl);
|
||||
|
||||
.logo.slim a {
|
||||
height: $baseline*3/2;
|
||||
margin-top: $baseline/5;
|
||||
}
|
||||
|
||||
.course-header {
|
||||
font-size: $font-size-lg;
|
||||
margin: $baseline/2 $baseline/2 0 0;
|
||||
|
||||
.provider {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin: 0 $baseline 0 0;
|
||||
font-weight: $font-weight-normal;
|
||||
font-family: $font-family-base;
|
||||
text-transform: uppercase;
|
||||
list-style: none;
|
||||
|
||||
.nav-link {
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.user-image-frame {
|
||||
max-width: $baseline*2;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
|
||||
// Dealing with creating a collapsed menu
|
||||
&.nav-item-open-collapsed-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: map-get($grid-breakpoints,md)) {
|
||||
&.nav-item-open-collapsed, &.nav-item-open-collapsed-only {
|
||||
display: initial;
|
||||
margin: $baseline/4 $baseline/2;
|
||||
|
||||
a {
|
||||
color: $brand-primary;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $input-border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.nav-item-hidden-collapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-shopping-cart{
|
||||
padding-top: 0.7rem; // $btn-padding-y-lg once themed
|
||||
}
|
||||
|
||||
.navbar-right .nav-item {
|
||||
@media (min-width: map-get($grid-breakpoints,md)) {
|
||||
.nav-link {
|
||||
text-transform: none;
|
||||
color: $brand-inverse;
|
||||
font-weight: $font-weight-bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&.dropdown {
|
||||
cursor: pointer;
|
||||
|
||||
.dropdown-item {
|
||||
text-transform: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
cms/static/sass/bootstrap/_variables.scss
Normal file
71
cms/static/sass/bootstrap/_variables.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
// Studio - bootstrap utilities - variables
|
||||
// ========================================
|
||||
|
||||
// #Units: Unit variables
|
||||
// #GRID: Grid and layout variables
|
||||
// #COLORS: Base, palette and theme color definitions + application
|
||||
// #TYPOGRAPHY: Font definitions and scales
|
||||
// #ICONS: Icon specific colors + other styling
|
||||
|
||||
// ----------------------------
|
||||
// #UNITS
|
||||
// ----------------------------
|
||||
$baseline: 20px !default;
|
||||
|
||||
// ----------------------------
|
||||
// #GRID
|
||||
// ----------------------------
|
||||
$studio-max-width: 1180px !default;
|
||||
|
||||
// ----------------------------
|
||||
// #COLORS
|
||||
// ----------------------------
|
||||
|
||||
$studio-gray: palette(grayscale, base) !default;
|
||||
$studio-background-color: palette(grayscale, x-back) !default;
|
||||
$studio-container-background-color: $white !default;
|
||||
$studio-border-color: palette(grayscale, back) !default;
|
||||
$studio-label-color: palette(grayscale, black) !default;
|
||||
$studio-active-color: palette(primary, base) !default;
|
||||
$studio-preview-menu-color: #c8c8c8 !default;
|
||||
$success-color: palette(success, accent) !default;
|
||||
$success-color-hover: palette(success, text) !default;
|
||||
|
||||
$button-bg-hover-color: $white !default;
|
||||
|
||||
$white-transparent: rgba(255, 255, 255, 0) !default;
|
||||
$white-opacity-40: rgba(255, 255, 255, 0.4) !default;
|
||||
$white-opacity-60: rgba(255, 255, 255, 0.6) !default;
|
||||
$white-opacity-70: rgba(255, 255, 255, 0.7) !default;
|
||||
$white-opacity-80: rgba(255, 255, 255, 0.8) !default;
|
||||
|
||||
$black: rgb(0,0,0) !default;
|
||||
$black-t0: rgba($black, 0.125) !default;
|
||||
$black-t1: rgba($black, 0.25) !default;
|
||||
$black-t2: rgba($black, 0.5) !default;
|
||||
$black-t3: rgba($black, 0.75) !default;
|
||||
|
||||
$light-grey-transparent: rgba(200,200,200, 0) !default;
|
||||
$light-grey-solid: rgba(200,200,200, 1) !default;
|
||||
|
||||
$header-bg: $white !default;
|
||||
$footer-bg: $white !default;
|
||||
|
||||
// ----------------------------
|
||||
// #TYPOGRAPHY
|
||||
// ----------------------------
|
||||
$font-light: 300 !default;
|
||||
$font-regular: 400 !default;
|
||||
$font-semibold: 600 !default;
|
||||
$font-bold: 700 !default;
|
||||
|
||||
// ----------------------------
|
||||
// #ICONS
|
||||
// ----------------------------
|
||||
// Icons
|
||||
$studio-dark-icon-color: $white !default;
|
||||
$studio-dark-icon-background-color: palette(grayscale, black) !default;
|
||||
|
||||
$site-status-color: rgb(182,37,103) !default;
|
||||
|
||||
$shadow-l1: rgba(0,0,0,0.1) !default;
|
||||
19
cms/static/sass/bootstrap/studio-main.scss
Normal file
19
cms/static/sass/bootstrap/studio-main.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
// -----------------------------
|
||||
// Studio main styles for Bootstrap
|
||||
// -----------------------------
|
||||
|
||||
// Bootstrap theme
|
||||
@import 'bootstrap/theme';
|
||||
@import 'bootstrap/scss/bootstrap';
|
||||
|
||||
// Variables
|
||||
@import 'mixins';
|
||||
@import 'variables';
|
||||
@import 'base';
|
||||
|
||||
// Elements
|
||||
@import 'header';
|
||||
@import 'footer';
|
||||
@import 'navigation';
|
||||
@import 'layouts';
|
||||
@import 'components';
|
||||
5
cms/static/sass/partials/bootstrap/_theme.scss
Normal file
5
cms/static/sass/partials/bootstrap/_theme.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
// 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 (
|
||||
<%static:css group='style-vendor-tinymce-content'/>
|
||||
<%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" />
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<!-- 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
|
||||
|
||||
-->
|
||||
408
cms/templates/ux/reference/bootstrap/test.html
Normal file
408
cms/templates/ux/reference/bootstrap/test.html
Normal file
@@ -0,0 +1,408 @@
|
||||
## Override the default styles_version to use Bootstrap
|
||||
<%! main_css = "css/bootstrap/studio-main.css" %>
|
||||
|
||||
<%page expression_filter="h"/>
|
||||
|
||||
<%!
|
||||
from openedx.core.djangoapps.util.user_messages import (
|
||||
register_error_message,
|
||||
register_info_message,
|
||||
register_success_message,
|
||||
register_warning_message,
|
||||
)
|
||||
%>
|
||||
|
||||
<%
|
||||
register_info_message(request, _('This is a test message'))
|
||||
%>
|
||||
|
||||
<%inherit file="/base.html" />
|
||||
<%block name="title">Bootstrap Test Page</%block>
|
||||
<%block name="bodyclass">bootstrap-test</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">Content</small>
|
||||
<span class="sr">> </span>Course Outline
|
||||
</h1>
|
||||
|
||||
<nav class="nav-actions" aria-label="Page Actions">
|
||||
<h3 class="sr">Page Actions</h3>
|
||||
<ul>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button button-new" data-category="chapter" data-parent="block-v1:AndyA+AA101+1+type@course+block@course" data-default-name="Section" title="Click to add a new section">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>New Section
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/course/course-v1:AndyA+AA101+1/search_reindex" class="button button-reindex" data-category="reindex" title="Reindex current course">
|
||||
<span class="icon-arrow-right" aria-hidden="true"></span>Reindex
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button button-toggle button-toggle-expand-collapse collapse-all">
|
||||
<span class="collapse-all"><span class="icon fa fa-arrow-up" aria-hidden="true"></span> <span class="label">Collapse All Sections</span></span>
|
||||
<span class="expand-all"><span class="icon fa fa-arrow-down" aria-hidden="true"></span> <span class="label">Expand All Sections</span></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="//localhost:8000/courses/course-v1:AndyA+AA101+1/jump_to/block-v1:AndyA+AA101+1+type@course+block@course" rel="external" class="button view-button view-live-button" title="Click to open the courseware in the LMS in a new tab" target="_blank">View Live</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
<div class="course-status">
|
||||
<div class="status-release">
|
||||
<h2 class="status-release-label">Course Start Date:</h2>
|
||||
<p class="status-release-value">Jan 01, 2015 at 00:00 UTC</p>
|
||||
|
||||
<ul class="status-actions">
|
||||
<li class="action-item action-edit">
|
||||
<a href="/settings/details/course-v1:AndyA+AA101+1" class="edit-button action-button" data-tooltip="Edit Start Date">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span>
|
||||
<span class="action-button-text sr">Edit Start Date</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-dnd" lang="en">
|
||||
|
||||
<h2 class="sr">Course Outline</h2>
|
||||
<article class="outline outline-complex outline-course" data-locator="block-v1:AndyA+AA101+1+type@course+block@course" data-course-key="course-v1:AndyA+AA101+1">
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="outline-content course-content">
|
||||
<ol class="list-sections is-sortable">
|
||||
<li class="ui-splint ui-splint-indicator">
|
||||
<span class="draggable-drop-indicator draggable-drop-indicator-initial"><span class="icon fa fa-caret-right" aria-hidden="true"></span></span>
|
||||
</li>
|
||||
<li class="outline-item outline-section is-live is-draggable is-collapsible " data-parent="block-v1:AndyA+AA101+1+type@course+block@course" data-locator="block-v1:AndyA+AA101+1+type@chapter+block@3a1a345f6bd94bb4abebe9e144cd03b6" style="position: relative;">
|
||||
|
||||
<span class="draggable-drop-indicator draggable-drop-indicator-before"><span class="icon fa fa-caret-right" aria-hidden="true"></span></span>
|
||||
|
||||
<div class="section-header">
|
||||
|
||||
<h3 class="section-header-details expand-collapse collapse ui-toggle-expansion" title="Collapse/Expand this section">
|
||||
<span class="icon fa fa-caret-down" aria-hidden="true"></span>
|
||||
|
||||
|
||||
<span class="wrapper-section-title wrapper-xblock-field incontext-editor is-editable" data-field="display_name" data-field-display-name="Display Name">
|
||||
<span class="section-title item-title xblock-field-value incontext-editor-value">Section</span>
|
||||
|
||||
<div class="incontext-editor-action-wrapper">
|
||||
<a href="" class="action-edit action-inline xblock-field-value-edit incontext-editor-open-action" title="Edit the name">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span><span class="sr"> Edit</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="xblock-string-field-editor incontext-editor-form">
|
||||
<form>
|
||||
|
||||
<label><span class="sr">Edit Display Name (required)</span>
|
||||
<input type="text" value="Section" class="xblock-field-input incontext-editor-input" data-metadata-name="display_name" title="Edit the name">
|
||||
</label>
|
||||
<button class="sr action action-primary" name="submit" type="submit">Save</button>
|
||||
<button class="sr action action-secondary" name="cancel" type="button">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</span>
|
||||
|
||||
</h3>
|
||||
|
||||
<div class="section-header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
<li class="action-item action-publish">
|
||||
<a href="#" data-tooltip="Publish" class="publish-button action-button">
|
||||
<span class="icon fa fa-upload" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Publish</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-configure">
|
||||
<a href="#" data-tooltip="Configure" class="configure-button action-button">
|
||||
<span class="icon fa fa-gear" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Configure</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">
|
||||
<span class="icon fa fa-copy" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Duplicate</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="Delete" class="delete-button action-button">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Delete</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle section-drag-handle action">
|
||||
<span class="sr">Drag to reorder</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-status">
|
||||
|
||||
|
||||
<div class="status-release">
|
||||
<p>
|
||||
<span class="sr status-release-label">Release Status:</span>
|
||||
<span class="status-release-value">
|
||||
|
||||
|
||||
<span class="icon fa fa-check" aria-hidden="true"></span>
|
||||
Released:
|
||||
|
||||
|
||||
Jan 01, 2015 at 00:00 UTC
|
||||
|
||||
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="status-hide-after-due">
|
||||
<p>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="outline-content section-content">
|
||||
<ol class="list-subsections is-sortable">
|
||||
<li class="ui-splint ui-splint-indicator">
|
||||
<span class="draggable-drop-indicator draggable-drop-indicator-initial"><span class="icon fa fa-caret-right" aria-hidden="true"></span></span>
|
||||
</li>
|
||||
<li class="outline-item outline-subsection is-live is-draggable is-collapsible is-collapsed" data-parent="block-v1:AndyA+AA101+1+type@chapter+block@3a1a345f6bd94bb4abebe9e144cd03b6" data-locator="block-v1:AndyA+AA101+1+type@sequential+block@dc1b2b84c9be4646a404f6425792eb90" style="position: relative;">
|
||||
|
||||
<span class="draggable-drop-indicator draggable-drop-indicator-before"><span class="icon fa fa-caret-right" aria-hidden="true"></span></span>
|
||||
|
||||
<div class="subsection-header">
|
||||
|
||||
<h3 class="subsection-header-details expand-collapse expand ui-toggle-expansion" title="Collapse/Expand this subsection">
|
||||
<span class="icon fa fa-caret-down" aria-hidden="true"></span>
|
||||
|
||||
|
||||
<span class="wrapper-subsection-title wrapper-xblock-field incontext-editor is-editable" data-field="display_name" data-field-display-name="Display Name">
|
||||
<span class="subsection-title item-title xblock-field-value incontext-editor-value">Subsection</span>
|
||||
|
||||
<div class="incontext-editor-action-wrapper">
|
||||
<a href="" class="action-edit action-inline xblock-field-value-edit incontext-editor-open-action" title="Edit the name">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span><span class="sr"> Edit</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="xblock-string-field-editor incontext-editor-form">
|
||||
<form>
|
||||
|
||||
<label><span class="sr">Edit Display Name (required)</span>
|
||||
<input type="text" value="Subsection" class="xblock-field-input incontext-editor-input" data-metadata-name="display_name" title="Edit the name">
|
||||
</label>
|
||||
<button class="sr action action-primary" name="submit" type="submit">Save</button>
|
||||
<button class="sr action action-secondary" name="cancel" type="button">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</span>
|
||||
|
||||
</h3>
|
||||
|
||||
<div class="subsection-header-actions">
|
||||
<ul class="actions-list">
|
||||
|
||||
<li class="action-item action-publish">
|
||||
<a href="#" data-tooltip="Publish" class="publish-button action-button">
|
||||
<span class="icon fa fa-upload" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Publish</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-configure">
|
||||
<a href="#" data-tooltip="Configure" class="configure-button action-button">
|
||||
<span class="icon fa fa-gear" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Configure</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">
|
||||
<span class="icon fa fa-copy" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Duplicate</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="Delete" class="delete-button action-button">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text">Delete</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle subsection-drag-handle action">
|
||||
<span class="sr">Drag to reorder</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subsection-status">
|
||||
|
||||
|
||||
<div class="status-release">
|
||||
<p>
|
||||
<span class="sr status-release-label">Release Status:</span>
|
||||
<span class="status-release-value">
|
||||
|
||||
|
||||
<span class="icon fa fa-check" aria-hidden="true"></span>
|
||||
Released:
|
||||
|
||||
|
||||
Jan 01, 2015 at 00:00 UTC
|
||||
|
||||
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="status-hide-after-due">
|
||||
<p>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="outline-content subsection-content">
|
||||
<ol class="list-units is-sortable">
|
||||
<li class="ui-splint ui-splint-indicator">
|
||||
<span class="draggable-drop-indicator draggable-drop-indicator-initial"><span class="icon fa fa-caret-right" aria-hidden="true"></span></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
|
||||
|
||||
<div class="add-unit add-item">
|
||||
<a href="#" class="button button-new" data-category="vertical" data-parent="block-v1:AndyA+AA101+1+type@sequential+block@dc1b2b84c9be4646a404f6425792eb90" data-default-name="Unit" title="Click to add a new Unit">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>New Unit
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<span class="draggable-drop-indicator draggable-drop-indicator-after"><span class="icon fa fa-caret-right" aria-hidden="true"></span></span>
|
||||
</li></ol>
|
||||
|
||||
|
||||
|
||||
<div class="add-subsection add-item">
|
||||
<a href="#" class="button button-new" data-category="sequential" data-parent="block-v1:AndyA+AA101+1+type@chapter+block@3a1a345f6bd94bb4abebe9e144cd03b6" data-default-name="Subsection" title="Click to add a new Subsection">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>New Subsection
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<span class="draggable-drop-indicator draggable-drop-indicator-after"><span class="icon fa fa-caret-right" aria-hidden="true"></span></span>
|
||||
</li></ol>
|
||||
|
||||
|
||||
|
||||
<div class="add-section add-item">
|
||||
<a href="#" class="button button-new" data-category="chapter" data-parent="block-v1:AndyA+AA101+1+type@course+block@course" data-default-name="Section" title="Click to add a new Section">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>New Section
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</article>
|
||||
</div>
|
||||
<div class="ui-loading is-hidden">
|
||||
<p><span class="spin"><span class="icon fa fa-refresh" aria-hidden="true"></span></span> <span class="copy">Loading</span></p>
|
||||
</div>
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">Creating your course organization</h3>
|
||||
<p>You add sections, subsections, and units directly in the outline.</p>
|
||||
<p>Create a section, then add subsections and units. Open a unit to add course components.</p>
|
||||
</div>
|
||||
<div class="bit">
|
||||
<h3 class="title-3">Reorganizing your course</h3>
|
||||
<p>Drag sections, subsections, and units to new locations in the outline.</p>
|
||||
<div class="external-help">
|
||||
<a href="http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_outline.html" target="_blank" class="button external-help-button">Learn more about the course outline</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bit">
|
||||
<h3 class="title-3">Setting release dates and grading policies</h3>
|
||||
<p>Select the Configure icon for a section or subsection to set its release date. When you configure a subsection, you can also set the grading policy and due date.</p>
|
||||
<div class="external-help">
|
||||
<a href="http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/grading/index.html" target="_blank" class="button external-help-button">Learn more about grading policy settings</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bit">
|
||||
<h3 class="title-3">Changing the content learners see</h3>
|
||||
<p>To publish draft content, select the Publish icon for a section, subsection, or unit.</p>
|
||||
<p>To make a section, subsection, or unit unavailable to learners, select the Configure icon for that level, then select the appropriate <strong>Hide</strong> option. Grades for hidden sections, subsections, and units are not included in grade calculations.</p>
|
||||
<p>To hide the content of a subsection from learners after the subsection due date has passed, select the Configure icon for a subsection, then select <strong>Hide content after due date</strong>. Grades for the subsection remain included in grade calculations.</p>
|
||||
<div class="external-help">
|
||||
<a href="http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/controlling_content_visibility.html" target="_blank" class="button external-help-button">Learn more about content visibility settings</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -1,35 +1,24 @@
|
||||
## Override the default styles_version to the Pattern Library version (version 2)
|
||||
<%! main_css = "style-main-v2" %>
|
||||
## Override the default styles_version to use Bootstrap
|
||||
<%! main_css = "css/bootstrap/studio-main.css" %>
|
||||
|
||||
<%page expression_filter="h"/>
|
||||
|
||||
<%inherit file="../../base.html" />
|
||||
|
||||
<%block name="view_notes">
|
||||
<%include file="_note-usage.html" />
|
||||
</%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">
|
||||
<div class="main-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<div class="main-column">
|
||||
<article class="window unit-body">
|
||||
<h1>UX Style Reference</h1>
|
||||
<h2>UX Style Reference</h2>
|
||||
|
||||
<ol class="components">
|
||||
<li class="component">
|
||||
<div class="wrapper wrapper-component-action-header">
|
||||
<h2>Page Types</h2>
|
||||
</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>
|
||||
<ul>
|
||||
<a href="bootstrap/test.html">Bootstrap Test Page</a>
|
||||
<a href="pattern-library/test.html">Pattern Library Test Page</a>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,6 +93,7 @@ source, template_path = Loader(engine).load_template_source(path)
|
||||
</%doc>
|
||||
<%
|
||||
from django.template import Template, Context
|
||||
from webpack_loader.exceptions import WebpackLoaderBadStatsError
|
||||
try:
|
||||
return Template("""
|
||||
{% load render_bundle from webpack_loader %}
|
||||
@@ -105,9 +106,9 @@ source, template_path = Loader(engine).load_template_source(path)
|
||||
'entry': entry,
|
||||
'body': capture(caller.body)
|
||||
}))
|
||||
except IOError as e:
|
||||
except (IOError, WebpackLoaderBadStatsError) as e:
|
||||
# Don't break Mako template rendering if the bundle or webpack-stats can't be found, but log it
|
||||
logger.error(e)
|
||||
logger.error('[LEARNER-1938] {error}'.format(error=e))
|
||||
%>
|
||||
</%def>
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ class GlobalStatusMessage(ConfigurationModel):
|
||||
msg = self.message
|
||||
if course_key:
|
||||
try:
|
||||
course_message = self.coursemessage_set.get(course_key=course_key)
|
||||
# Don't add the message if course_message is blank.
|
||||
if course_message:
|
||||
msg = u"{} <br /> {}".format(msg, course_message.message)
|
||||
course_home_message = self.coursemessage_set.get(course_key=course_key)
|
||||
# Don't add the message if course_home_message is blank.
|
||||
if course_home_message:
|
||||
msg = u"{} <br /> {}".format(msg, course_home_message.message)
|
||||
except CourseMessage.DoesNotExist:
|
||||
# We don't have a course-specific message, so pass.
|
||||
pass
|
||||
|
||||
@@ -734,13 +734,12 @@ def dashboard(request):
|
||||
show_courseware_links_for = frozenset(
|
||||
enrollment.course_id for enrollment in course_enrollments
|
||||
if has_access(request.user, 'load', enrollment.course_overview)
|
||||
and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
|
||||
)
|
||||
|
||||
# Find programs associated with course runs being displayed. This information
|
||||
# is passed in the template context to allow rendering of program-related
|
||||
# information on the dashboard.
|
||||
meter = ProgramProgressMeter(user, enrollments=course_enrollments)
|
||||
meter = ProgramProgressMeter(request.site, user, enrollments=course_enrollments)
|
||||
inverted_programs = meter.invert_programs()
|
||||
|
||||
# Construct a dictionary of course mode information
|
||||
|
||||
@@ -23,32 +23,3 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware):
|
||||
redirect_uri = pipeline.AUTH_DISPATCH_URLS[auth_entry]
|
||||
|
||||
return redirect_uri
|
||||
|
||||
|
||||
class PipelineQuarantineMiddleware(object):
|
||||
"""
|
||||
Middleware flushes the session if a user agent with a quarantined session
|
||||
attempts to leave the quarantined set of views.
|
||||
"""
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Check the session to see if we've quarantined the user to a particular
|
||||
step of the authentication pipeline; if so, look up which modules the
|
||||
user is allowed to browse to without breaking the pipeline. If the view
|
||||
that's been requested is outside those modules, then flush the session.
|
||||
|
||||
In general, this middleware should be used in cases where allowing the
|
||||
user to exit the running pipeline would be undesirable, and where it'd
|
||||
be better to flush the session state rather than allow it. Pipeline
|
||||
quarantining is utilized by the Enterprise application to enforce
|
||||
collection of user consent for sharing data with a linked third-party
|
||||
authentication provider.
|
||||
"""
|
||||
if not pipeline.running(request):
|
||||
return
|
||||
|
||||
view_module = view_func.__module__
|
||||
quarantined_modules = request.session.get('third_party_auth_quarantined_modules')
|
||||
if quarantined_modules is not None and not any(view_module.startswith(mod) for mod in quarantined_modules):
|
||||
request.session.flush()
|
||||
|
||||
@@ -10,12 +10,9 @@ If true, it:
|
||||
b) calls apply_settings(), passing in the Django settings
|
||||
"""
|
||||
|
||||
from openedx.features.enterprise_support.api import insert_enterprise_pipeline_elements
|
||||
|
||||
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next']
|
||||
_MIDDLEWARE_CLASSES = (
|
||||
'third_party_auth.middleware.ExceptionMiddleware',
|
||||
'third_party_auth.middleware.PipelineQuarantineMiddleware',
|
||||
)
|
||||
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
|
||||
|
||||
@@ -58,9 +55,6 @@ def apply_settings(django_settings):
|
||||
'third_party_auth.pipeline.login_analytics',
|
||||
]
|
||||
|
||||
# Add enterprise pipeline elements if the enterprise app is installed
|
||||
insert_enterprise_pipeline_elements(django_settings.SOCIAL_AUTH_PIPELINE)
|
||||
|
||||
# Required so that we can use unmodified PSA OAuth2 backends:
|
||||
django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy'
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
Test the session-flushing middleware
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import Client
|
||||
from social_django.models import Partial
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class TestSessionFlushMiddleware(unittest.TestCase):
|
||||
"""
|
||||
Ensure that if the pipeline is exited when it's been quarantined,
|
||||
the entire session is flushed.
|
||||
"""
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.fancy_variable = 13025
|
||||
self.token = 'pipeline_running'
|
||||
self.tpa_quarantined_modules = ('fake_quarantined_module',)
|
||||
|
||||
def tearDown(self):
|
||||
Partial.objects.all().delete()
|
||||
|
||||
def test_session_flush(self):
|
||||
"""
|
||||
Test that a quarantined session is flushed when navigating elsewhere
|
||||
"""
|
||||
session = self.client.session
|
||||
session['fancy_variable'] = self.fancy_variable
|
||||
session['partial_pipeline_token'] = self.token
|
||||
session['third_party_auth_quarantined_modules'] = self.tpa_quarantined_modules
|
||||
session.save()
|
||||
Partial.objects.create(token=session.get('partial_pipeline_token'))
|
||||
self.client.get('/')
|
||||
self.assertEqual(self.client.session.get('fancy_variable', None), None)
|
||||
|
||||
def test_session_no_running_pipeline(self):
|
||||
"""
|
||||
Test that a quarantined session without a running pipeline is not flushed
|
||||
"""
|
||||
session = self.client.session
|
||||
session['fancy_variable'] = self.fancy_variable
|
||||
session['third_party_auth_quarantined_modules'] = self.tpa_quarantined_modules
|
||||
session.save()
|
||||
self.client.get('/')
|
||||
self.assertEqual(self.client.session.get('fancy_variable', None), self.fancy_variable)
|
||||
|
||||
def test_session_no_quarantine(self):
|
||||
"""
|
||||
Test that a session with a running pipeline but no quarantine is not flushed
|
||||
"""
|
||||
session = self.client.session
|
||||
session['fancy_variable'] = self.fancy_variable
|
||||
session['partial_pipeline_token'] = self.token
|
||||
session.save()
|
||||
Partial.objects.create(token=session.get('partial_pipeline_token'))
|
||||
self.client.get('/')
|
||||
self.assertEqual(self.client.session.get('fancy_variable', None), self.fancy_variable)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from openedx.features.enterprise_support.api import enterprise_enabled
|
||||
from third_party_auth import provider, settings
|
||||
from third_party_auth.tests import testutil
|
||||
|
||||
@@ -56,8 +55,3 @@ class SettingsUnitTest(testutil.TestCase):
|
||||
# bad in prod.
|
||||
settings.apply_settings(self.settings)
|
||||
self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS)
|
||||
|
||||
@unittest.skipUnless(enterprise_enabled(), 'enterprise not enabled')
|
||||
def test_enterprise_elements_inserted(self):
|
||||
settings.apply_settings(self.settings)
|
||||
self.assertIn('enterprise.tpa_pipeline.handle_enterprise_logistration', self.settings.SOCIAL_AUTH_PIPELINE)
|
||||
|
||||
@@ -7,7 +7,6 @@ import logging
|
||||
import re
|
||||
import json
|
||||
import datetime
|
||||
import traceback
|
||||
|
||||
from pytz import UTC
|
||||
from collections import defaultdict
|
||||
@@ -158,19 +157,7 @@ class ActiveBulkThread(threading.local):
|
||||
"""
|
||||
def __init__(self, bulk_ops_record_type, **kwargs):
|
||||
super(ActiveBulkThread, self).__init__(**kwargs)
|
||||
self._records = defaultdict(bulk_ops_record_type)
|
||||
self.CMS_LEAK_DEBUG_GLOBAL = True # only log once per process
|
||||
|
||||
@property
|
||||
def records(self):
|
||||
if self.CMS_LEAK_DEBUG_GLOBAL and len(self._records) > 2000: # arbitrary limit, we peak around ~2750 on edx.org
|
||||
log.info(
|
||||
"EDUCATOR-768: The memory leak issue may be in progress. How we got here:\n{}".format(
|
||||
"".join(traceback.format_stack())
|
||||
)
|
||||
)
|
||||
self.CMS_LEAK_DEBUG_GLOBAL = False
|
||||
return self._records
|
||||
self.records = defaultdict(bulk_ops_record_type)
|
||||
|
||||
|
||||
class BulkOperationsMixin(object):
|
||||
|
||||
@@ -788,7 +788,8 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
if should_cache_items:
|
||||
self.cache_items(runtime, block_keys, course_entry.course_key, depth, lazy)
|
||||
|
||||
return [runtime.load_item(block_key, course_entry, **kwargs) for block_key in block_keys]
|
||||
with self.bulk_operations(course_entry.course_key, emit_signals=False):
|
||||
return [runtime.load_item(block_key, course_entry, **kwargs) for block_key in block_keys]
|
||||
|
||||
def _get_cache(self, course_version_guid):
|
||||
"""
|
||||
|
||||
@@ -418,7 +418,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
# wildcard query, 6! load pertinent items for inheritance calls, load parents, course root fetch (why)
|
||||
# Split:
|
||||
# active_versions (with regex), structure, and spurious active_versions refetch
|
||||
@ddt.data((ModuleStoreEnum.Type.mongo, 14, 0), (ModuleStoreEnum.Type.split, 3, 0))
|
||||
@ddt.data((ModuleStoreEnum.Type.mongo, 14, 0), (ModuleStoreEnum.Type.split, 4, 0))
|
||||
@ddt.unpack
|
||||
def test_get_items(self, default_ms, max_find, max_send):
|
||||
self.initdb(default_ms)
|
||||
@@ -1043,7 +1043,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
|
||||
# 1) wildcard split search,
|
||||
# 2-4) active_versions, structure, definition (s/b lazy; so, unnecessary)
|
||||
# 5) wildcard draft mongo which has none
|
||||
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 5, 0))
|
||||
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0), (ModuleStoreEnum.Type.split, 6, 0))
|
||||
@ddt.unpack
|
||||
def test_get_courses(self, default_ms, max_find, max_send):
|
||||
self.initdb(default_ms)
|
||||
|
||||
@@ -21,7 +21,11 @@ from django.utils.timezone import UTC
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from xblock.core import XBlock
|
||||
|
||||
from courseware.access_response import MilestoneError, MobileAvailabilityError, VisibilityError
|
||||
from courseware.access_response import (
|
||||
MilestoneAccessError,
|
||||
MobileAvailabilityError,
|
||||
VisibilityError,
|
||||
)
|
||||
from courseware.access_utils import (
|
||||
ACCESS_DENIED,
|
||||
ACCESS_GRANTED,
|
||||
@@ -309,7 +313,8 @@ def _has_access_course(user, action, courselike):
|
||||
"""
|
||||
response = (
|
||||
_visible_to_nonstaff_users(courselike) and
|
||||
check_course_open_for_learner(user, courselike)
|
||||
check_course_open_for_learner(user, courselike) and
|
||||
_can_view_courseware_with_prerequisites(user, courselike)
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -355,8 +360,6 @@ def _has_access_course(user, action, courselike):
|
||||
|
||||
checkers = {
|
||||
'load': can_load,
|
||||
'view_courseware_with_prerequisites':
|
||||
lambda: _can_view_courseware_with_prerequisites(user, courselike),
|
||||
'load_mobile': lambda: can_load() and _can_load_course_on_mobile(user, courselike),
|
||||
'enroll': can_enroll,
|
||||
'see_exists': see_exists,
|
||||
@@ -770,7 +773,7 @@ def _has_fulfilled_all_milestones(user, course_id):
|
||||
course_id: ID of the course to check
|
||||
user_id: ID of the user to check
|
||||
"""
|
||||
return MilestoneError() if any_unfulfilled_milestones(course_id, user.id) else ACCESS_GRANTED
|
||||
return MilestoneAccessError() if any_unfulfilled_milestones(course_id, user.id) else ACCESS_GRANTED
|
||||
|
||||
|
||||
def _has_fulfilled_prerequisites(user, course_id):
|
||||
@@ -782,7 +785,7 @@ def _has_fulfilled_prerequisites(user, course_id):
|
||||
user: user to check
|
||||
course_id: ID of the course to check
|
||||
"""
|
||||
return MilestoneError() if get_pre_requisite_courses_not_completed(user, course_id) else ACCESS_GRANTED
|
||||
return MilestoneAccessError() if get_pre_requisite_courses_not_completed(user, course_id) else ACCESS_GRANTED
|
||||
|
||||
|
||||
def _has_catalog_visibility(course, visibility_type):
|
||||
|
||||
@@ -105,7 +105,7 @@ class StartDateError(AccessError):
|
||||
super(StartDateError, self).__init__(error_code, developer_message, user_message)
|
||||
|
||||
|
||||
class MilestoneError(AccessError):
|
||||
class MilestoneAccessError(AccessError):
|
||||
"""
|
||||
Access denied because the user has unfulfilled milestones
|
||||
"""
|
||||
@@ -113,7 +113,7 @@ class MilestoneError(AccessError):
|
||||
error_code = "unfulfilled_milestones"
|
||||
developer_message = "User has unfulfilled milestones"
|
||||
user_message = _("You have unfulfilled milestones")
|
||||
super(MilestoneError, self).__init__(error_code, developer_message, user_message)
|
||||
super(MilestoneAccessError, self).__init__(error_code, developer_message, user_message)
|
||||
|
||||
|
||||
class VisibilityError(AccessError):
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime
|
||||
import branding
|
||||
import pytz
|
||||
from courseware.access import has_access
|
||||
from courseware.access_response import StartDateError
|
||||
from courseware.access_response import StartDateError, MilestoneAccessError
|
||||
from courseware.date_summary import (
|
||||
CourseEndDate,
|
||||
CourseStartDate,
|
||||
@@ -32,6 +32,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
|
||||
from path import Path as path
|
||||
from static_replace import replace_static_urls
|
||||
from student.models import CourseEnrollment
|
||||
from survey.utils import is_survey_required_and_unanswered
|
||||
from util.date_utils import strftime_localized
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -72,7 +73,7 @@ def get_course_by_id(course_key, depth=0):
|
||||
raise Http404("Course not found: {}.".format(unicode(course_key)))
|
||||
|
||||
|
||||
def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False):
|
||||
def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False, check_survey_complete=True):
|
||||
"""
|
||||
Given a course_key, look up the corresponding course descriptor,
|
||||
check that the user has the access to perform the specified action
|
||||
@@ -84,9 +85,14 @@ def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=
|
||||
|
||||
check_if_enrolled: If true, additionally verifies that the user is either enrolled in the course
|
||||
or has staff access.
|
||||
check_survey_complete: If true, additionally verifies that the user has either completed the course survey
|
||||
or has staff access.
|
||||
Note: We do not want to continually add these optional booleans. Ideally,
|
||||
these special cases could not only be handled inside has_access, but could
|
||||
be plugged in as additional callback checks for different actions.
|
||||
"""
|
||||
course = get_course_by_id(course_key, depth)
|
||||
check_course_access(course, user, action, check_if_enrolled)
|
||||
check_course_access(course, user, action, check_if_enrolled, check_survey_complete)
|
||||
return course
|
||||
|
||||
|
||||
@@ -109,12 +115,13 @@ def get_course_overview_with_access(user, action, course_key, check_if_enrolled=
|
||||
return course_overview
|
||||
|
||||
|
||||
def check_course_access(course, user, action, check_if_enrolled=False):
|
||||
def check_course_access(course, user, action, check_if_enrolled=False, check_survey_complete=True):
|
||||
"""
|
||||
Check that the user has the access to perform the specified action
|
||||
on the course (CourseDescriptor|CourseOverview).
|
||||
|
||||
check_if_enrolled: If true, additionally verifies that the user is enrolled.
|
||||
check_survey_complete: If true, additionally verifies that the user has completed the survey.
|
||||
"""
|
||||
# Allow staff full access to the course even if not enrolled
|
||||
if has_access(user, 'staff', course.id):
|
||||
@@ -130,7 +137,13 @@ def check_course_access(course, user, action, check_if_enrolled=False):
|
||||
raise CourseAccessRedirect('{dashboard_url}?{params}'.format(
|
||||
dashboard_url=reverse('dashboard'),
|
||||
params=params.urlencode()
|
||||
))
|
||||
), access_response)
|
||||
|
||||
# Redirect if the user must answer a survey before entering the course.
|
||||
if isinstance(access_response, MilestoneAccessError):
|
||||
raise CourseAccessRedirect('{dashboard_url}'.format(
|
||||
dashboard_url=reverse('dashboard'),
|
||||
), access_response)
|
||||
|
||||
# Deliberately return a non-specific error message to avoid
|
||||
# leaking info about access control settings
|
||||
@@ -141,6 +154,11 @@ def check_course_access(course, user, action, check_if_enrolled=False):
|
||||
if not CourseEnrollment.is_enrolled(user, course.id):
|
||||
raise CourseAccessRedirect(reverse('about_course', args=[unicode(course.id)]))
|
||||
|
||||
# Redirect if the user must answer a survey before entering the course.
|
||||
if check_survey_complete and action == 'load':
|
||||
if is_survey_required_and_unanswered(user, course):
|
||||
raise CourseAccessRedirect(reverse('course_survey', args=[unicode(course.id)]))
|
||||
|
||||
|
||||
def can_self_enroll_in_course(course_key):
|
||||
"""
|
||||
|
||||
@@ -15,5 +15,13 @@ class Redirect(Exception):
|
||||
class CourseAccessRedirect(Redirect):
|
||||
"""
|
||||
Redirect raised when user does not have access to a course.
|
||||
|
||||
Arguments:
|
||||
url (string): The redirect url.
|
||||
access_error (AccessErro): The AccessError that caused the redirect.
|
||||
The AccessError contains messages for developers and users explaining why
|
||||
the user was denied access. These strings can then be exposed to the user.
|
||||
"""
|
||||
pass
|
||||
def __init__(self, url, access_error=None):
|
||||
super(CourseAccessRedirect, self).__init__(url)
|
||||
self.access_error = access_error
|
||||
|
||||
@@ -595,16 +595,16 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
|
||||
|
||||
# user should not be able to load course even if enrolled
|
||||
CourseEnrollmentFactory(user=user, course_id=course.id)
|
||||
response = access._has_access_course(user, 'view_courseware_with_prerequisites', course)
|
||||
response = access._has_access_course(user, 'load', course)
|
||||
self.assertFalse(response)
|
||||
self.assertIsInstance(response, access_response.MilestoneError)
|
||||
self.assertIsInstance(response, access_response.MilestoneAccessError)
|
||||
# Staff can always access course
|
||||
staff = StaffFactory.create(course_key=course.id)
|
||||
self.assertTrue(access._has_access_course(staff, 'view_courseware_with_prerequisites', course))
|
||||
self.assertTrue(access._has_access_course(staff, 'load', course))
|
||||
|
||||
# User should be able access after completing required course
|
||||
fulfill_course_milestone(pre_requisite_course.id, user)
|
||||
self.assertTrue(access._has_access_course(user, 'view_courseware_with_prerequisites', course))
|
||||
self.assertTrue(access._has_access_course(user, 'load', course))
|
||||
|
||||
@ddt.data(
|
||||
(True, True, True),
|
||||
@@ -615,8 +615,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
|
||||
"""
|
||||
Test course access on mobile for staff and students.
|
||||
"""
|
||||
descriptor = Mock(id=self.course.id, user_partitions=[])
|
||||
descriptor._class_tags = {}
|
||||
descriptor = CourseFactory()
|
||||
descriptor.visible_to_staff_only = False
|
||||
descriptor.mobile_available = mobile_available
|
||||
|
||||
@@ -773,7 +772,7 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
|
||||
|
||||
PREREQUISITES_TEST_DATA = list(itertools.product(
|
||||
['user_normal', 'user_completed_pre_requisite', 'user_staff', 'user_anonymous'],
|
||||
['view_courseware_with_prerequisites'],
|
||||
['load'],
|
||||
['course_default', 'course_with_pre_requisite', 'course_with_pre_requisites'],
|
||||
))
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_num_queries_instructor_paced(self):
|
||||
self.fetch_course_info_with_queries(self.instructor_paced_course, 25, 3)
|
||||
self.fetch_course_info_with_queries(self.instructor_paced_course, 24, 3)
|
||||
|
||||
def test_num_queries_self_paced(self):
|
||||
self.fetch_course_info_with_queries(self.self_paced_course, 25, 3)
|
||||
self.fetch_course_info_with_queries(self.self_paced_course, 24, 3)
|
||||
|
||||
@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
|
||||
NUM_PROBLEMS = 20
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.mongo, 10, 143),
|
||||
(ModuleStoreEnum.Type.split, 4, 143),
|
||||
(ModuleStoreEnum.Type.mongo, 10, 142),
|
||||
(ModuleStoreEnum.Type.split, 4, 142),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
|
||||
@@ -1464,12 +1464,12 @@ class ProgressPageTests(ProgressPageBaseTests):
|
||||
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
|
||||
SelfPacedConfiguration(enabled=self_paced_enabled).save()
|
||||
self.setup_course(self_paced=self_paced)
|
||||
with self.assertNumQueries(40, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
|
||||
with self.assertNumQueries(39, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
|
||||
self._get_progress_page()
|
||||
|
||||
@ddt.data(
|
||||
(False, 40, 26),
|
||||
(True, 33, 22)
|
||||
(False, 39, 25),
|
||||
(True, 32, 21)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_progress_queries(self, enable_waffle, initial, subsequent):
|
||||
|
||||
@@ -52,7 +52,6 @@ from ..model_data import FieldDataCache
|
||||
from ..module_render import get_module_for_descriptor, toc_for_course
|
||||
from .views import (
|
||||
CourseTabView,
|
||||
check_access_to_course,
|
||||
check_and_get_upgrade_link,
|
||||
get_cosmetic_verified_display_price
|
||||
)
|
||||
@@ -136,7 +135,6 @@ class CoursewareIndex(View):
|
||||
"""
|
||||
Render the index page.
|
||||
"""
|
||||
check_access_to_course(request, self.course)
|
||||
self._redirect_if_needed_to_pay_for_course()
|
||||
self._prefetch_and_bind_course(request)
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from datetime import datetime, timedelta
|
||||
|
||||
import analytics
|
||||
import shoppingcart
|
||||
import survey.utils
|
||||
import survey.views
|
||||
import waffle
|
||||
from certificates import api as certs_api
|
||||
@@ -82,7 +81,7 @@ from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.util.user_messages import register_warning_message
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, course_home_url_name
|
||||
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
|
||||
@@ -91,7 +90,6 @@ from openedx.features.enterprise_support.api import data_sharing_consent_require
|
||||
from rest_framework import status
|
||||
from shoppingcart.utils import is_shopping_cart_enabled
|
||||
from student.models import CourseEnrollment, UserTestGroup
|
||||
from survey.utils import must_answer_survey
|
||||
from util.cache import cache, cache_if_anonymous
|
||||
from util.db import outer_atomic
|
||||
from util.milestones_helpers import get_prerequisite_courses_display
|
||||
@@ -278,10 +276,6 @@ def course_info(request, course_id):
|
||||
if not user_is_enrolled and not can_self_enroll_in_course(course_key):
|
||||
return redirect(reverse('dashboard'))
|
||||
|
||||
# TODO: LEARNER-1865: Handle prereqs and course survey in new Course Home.
|
||||
# Redirect the user if they are not yet allowed to view this course
|
||||
check_access_to_course(request, course)
|
||||
|
||||
# LEARNER-170: Entrance exam is handled by new Course Outline. (DONE)
|
||||
# If the user needs to take an entrance exam to access this course, then we'll need
|
||||
# to send them to that specific course module before allowing them into other areas
|
||||
@@ -424,9 +418,6 @@ class CourseTabView(EdxFragmentView):
|
||||
with modulestore().bulk_operations(course_key):
|
||||
course = get_course_with_access(request.user, 'load', course_key)
|
||||
try:
|
||||
# Verify that the user has access to the course
|
||||
check_access_to_course(request, course)
|
||||
|
||||
# Show warnings if the user has limited access
|
||||
self.register_user_access_warning_messages(request, course_key)
|
||||
|
||||
@@ -456,7 +447,7 @@ class CourseTabView(EdxFragmentView):
|
||||
is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key)
|
||||
is_staff = has_access(request.user, 'staff', course_key)
|
||||
if request.user.is_anonymous():
|
||||
register_warning_message(
|
||||
PageLevelMessages.register_warning_message(
|
||||
request,
|
||||
Text(_("To see course content, {sign_in_link} or {register_link}.")).format(
|
||||
sign_in_link=HTML('<a href="/login?next={current_url}">{sign_in_label}</a>').format(
|
||||
@@ -470,7 +461,7 @@ class CourseTabView(EdxFragmentView):
|
||||
)
|
||||
)
|
||||
elif not is_enrolled and not is_staff:
|
||||
register_warning_message(
|
||||
PageLevelMessages.register_warning_message(
|
||||
request,
|
||||
Text(_('You must be enrolled in the course to see course content. {enroll_link}.')).format(
|
||||
enroll_link=HTML('<a href="{url_to_enroll}">{enroll_link_label}</a>').format(
|
||||
@@ -739,8 +730,7 @@ def course_about(request, course_id):
|
||||
|
||||
show_courseware_link = bool(
|
||||
(
|
||||
has_access(request.user, 'load', course) and
|
||||
has_access(request.user, 'view_courseware_with_prerequisites', course)
|
||||
has_access(request.user, 'load', course)
|
||||
) or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
|
||||
)
|
||||
|
||||
@@ -848,7 +838,7 @@ def program_marketing(request, program_uuid):
|
||||
"""
|
||||
Display the program marketing page.
|
||||
"""
|
||||
program_data = get_programs(uuid=program_uuid)
|
||||
program_data = get_programs(request.site, uuid=program_uuid)
|
||||
|
||||
if not program_data:
|
||||
raise Http404
|
||||
@@ -921,9 +911,6 @@ def _progress(request, course_key, student_id):
|
||||
# NOTE: To make sure impersonation by instructor works, use
|
||||
# student instead of request.user in the rest of the function.
|
||||
|
||||
# Redirect the user if they are not yet allowed to view this course
|
||||
check_access_to_course(request, course)
|
||||
|
||||
# The pre-fetching of groups is done to make auth checks not require an
|
||||
# additional DB lookup (this kills the Progress page in particular).
|
||||
student = User.objects.prefetch_related("groups").get(id=student.id)
|
||||
@@ -1311,7 +1298,7 @@ def course_survey(request, course_id):
|
||||
"""
|
||||
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_with_access(request.user, 'load', course_key)
|
||||
course = get_course_with_access(request.user, 'load', course_key, check_survey_complete=False)
|
||||
|
||||
redirect_url = reverse(course_home_url_name(course.id), args=[course_id])
|
||||
|
||||
@@ -1721,22 +1708,3 @@ def get_financial_aid_courses(user):
|
||||
)
|
||||
|
||||
return financial_aid_courses
|
||||
|
||||
|
||||
def check_access_to_course(request, course):
|
||||
"""
|
||||
Raises Redirect exceptions if the user does not have course access.
|
||||
"""
|
||||
# TODO: LEARNER-1865: Handle prereqs in new Course Home.
|
||||
# Redirect to the dashboard if not all prerequisites have been met
|
||||
if not has_access(request.user, 'view_courseware_with_prerequisites', course):
|
||||
log.info(
|
||||
u'User %d tried to view course %s '
|
||||
u'without fulfilling prerequisites',
|
||||
request.user.id, unicode(course.id))
|
||||
raise CourseAccessRedirect(reverse('dashboard'))
|
||||
|
||||
# TODO: LEARNER-1865: Handle course surveys in new Course Home.
|
||||
# Redirect if the user must answer a survey before entering the course.
|
||||
if must_answer_survey(course, request.user):
|
||||
raise CourseAccessRedirect(reverse('course_survey', args=[unicode(course.id)]))
|
||||
|
||||
@@ -404,8 +404,8 @@ class ViewsQueryCountTestCase(
|
||||
return inner
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.mongo, 3, 4, 32),
|
||||
(ModuleStoreEnum.Type.split, 3, 12, 32),
|
||||
(ModuleStoreEnum.Type.mongo, 3, 4, 31),
|
||||
(ModuleStoreEnum.Type.split, 3, 13, 31),
|
||||
)
|
||||
@ddt.unpack
|
||||
@count_queries
|
||||
@@ -413,8 +413,8 @@ class ViewsQueryCountTestCase(
|
||||
self.create_thread_helper(mock_request)
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.mongo, 3, 3, 28),
|
||||
(ModuleStoreEnum.Type.split, 3, 9, 28),
|
||||
(ModuleStoreEnum.Type.mongo, 3, 3, 27),
|
||||
(ModuleStoreEnum.Type.split, 3, 10, 27),
|
||||
)
|
||||
@ddt.unpack
|
||||
@count_queries
|
||||
|
||||
@@ -21,7 +21,7 @@ class CourseGradeBase(object):
|
||||
"""
|
||||
Base class for Course Grades.
|
||||
"""
|
||||
def __init__(self, user, course_data, percent=0, letter_grade=None, passed=False):
|
||||
def __init__(self, user, course_data, percent=0, letter_grade=None, passed=False, force_update_subsections=False):
|
||||
self.user = user
|
||||
self.course_data = course_data
|
||||
|
||||
@@ -30,6 +30,7 @@ class CourseGradeBase(object):
|
||||
|
||||
# Convert empty strings to None when reading from the table
|
||||
self.letter_grade = letter_grade or None
|
||||
self.force_update_subsections = force_update_subsections
|
||||
|
||||
def __unicode__(self):
|
||||
return u'Course Grade: percent: {}, letter_grade: {}, passed: {}'.format(
|
||||
@@ -203,7 +204,9 @@ class CourseGrade(CourseGradeBase):
|
||||
|
||||
def update(self):
|
||||
"""
|
||||
Updates the grade for the course.
|
||||
Updates the grade for the course. Also updates subsection grades
|
||||
if self.force_update_subsections is true, via the lazy call
|
||||
to self.grader_result.
|
||||
"""
|
||||
grade_cutoffs = self.course_data.course.grade_cutoffs
|
||||
self.percent = self._compute_percent(self.grader_result)
|
||||
@@ -224,7 +227,10 @@ class CourseGrade(CourseGradeBase):
|
||||
|
||||
def _get_subsection_grade(self, subsection):
|
||||
# Pass read_only here so the subsection grades can be persisted in bulk at the end.
|
||||
return self._subsection_grade_factory.create(subsection, read_only=True)
|
||||
if self.force_update_subsections:
|
||||
return self._subsection_grade_factory.update(subsection)
|
||||
else:
|
||||
return self._subsection_grade_factory.create(subsection, read_only=True)
|
||||
|
||||
@staticmethod
|
||||
def _compute_percent(grader_result):
|
||||
|
||||
@@ -66,7 +66,15 @@ class CourseGradeFactory(object):
|
||||
else:
|
||||
return None
|
||||
|
||||
def update(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None):
|
||||
def update(
|
||||
self,
|
||||
user,
|
||||
course=None,
|
||||
collected_block_structure=None,
|
||||
course_structure=None,
|
||||
course_key=None,
|
||||
force_update_subsections=False,
|
||||
):
|
||||
"""
|
||||
Computes, updates, and returns the CourseGrade for the given
|
||||
user in the course.
|
||||
@@ -75,7 +83,7 @@ class CourseGradeFactory(object):
|
||||
or course_key should be provided.
|
||||
"""
|
||||
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
|
||||
return self._update(user, course_data, read_only=False)
|
||||
return self._update(user, course_data, read_only=False, force_update_subsections=force_update_subsections)
|
||||
|
||||
@contextmanager
|
||||
def _course_transaction(self, course_key):
|
||||
@@ -118,10 +126,17 @@ class CourseGradeFactory(object):
|
||||
|
||||
def _iter_grade_result(self, user, course_data, force_update):
|
||||
try:
|
||||
kwargs = {
|
||||
'user': user,
|
||||
'course': course_data.course,
|
||||
'collected_block_structure': course_data.collected_structure,
|
||||
'course_key': course_data.course_key
|
||||
}
|
||||
if force_update:
|
||||
kwargs['force_update_subsections'] = True
|
||||
|
||||
method = CourseGradeFactory().update if force_update else CourseGradeFactory().create
|
||||
course_grade = method(
|
||||
user, course_data.course, course_data.collected_structure, course_key=course_data.course_key,
|
||||
)
|
||||
course_grade = method(**kwargs)
|
||||
return self.GradeResult(user, course_grade, None)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Keep marching on even if this student couldn't be graded for
|
||||
@@ -165,14 +180,14 @@ class CourseGradeFactory(object):
|
||||
return course_grade, persistent_grade.grading_policy_hash
|
||||
|
||||
@staticmethod
|
||||
def _update(user, course_data, read_only):
|
||||
def _update(user, course_data, read_only, force_update_subsections=False):
|
||||
"""
|
||||
Computes, saves, and returns a CourseGrade object for the
|
||||
given user and course.
|
||||
Sends a COURSE_GRADE_CHANGED signal to listeners and a
|
||||
COURSE_GRADE_NOW_PASSED if learner has passed course.
|
||||
"""
|
||||
course_grade = CourseGrade(user, course_data)
|
||||
course_grade = CourseGrade(user, course_data, force_update_subsections=force_update_subsections)
|
||||
course_grade.update()
|
||||
|
||||
should_persist = (
|
||||
|
||||
@@ -106,7 +106,7 @@ def compute_grades_for_course_v2(self, **kwargs):
|
||||
@task(base=_BaseTask)
|
||||
def compute_grades_for_course(course_key, offset, batch_size, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Compute grades for a set of students in the specified course.
|
||||
Compute and save grades for a set of students in the specified course.
|
||||
|
||||
The set of students will be determined by the order of enrollment date, and
|
||||
limited to at most <batch_size> students, starting from the specified
|
||||
|
||||
@@ -246,6 +246,20 @@ class TestCourseGradeFactory(GradeTestBase):
|
||||
else:
|
||||
self.assertIsNone(course_grade)
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_iter_force_update(self, force_update):
|
||||
base_string = 'lms.djangoapps.grades.new.subsection_grade_factory.SubsectionGradeFactory.{}'
|
||||
desired_method_name = base_string.format('update' if force_update else 'create')
|
||||
undesired_method_name = base_string.format('create' if force_update else 'update')
|
||||
with patch(desired_method_name) as desired_call:
|
||||
with patch(undesired_method_name) as undesired_call:
|
||||
set(CourseGradeFactory().iter(
|
||||
users=[self.request.user], course=self.course, force_update=force_update
|
||||
))
|
||||
|
||||
self.assertTrue(desired_call.called)
|
||||
self.assertFalse(undesired_call.called)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestSubsectionGradeFactory(ProblemSubmissionTestMixin, GradeTestBase):
|
||||
|
||||
@@ -25,7 +25,7 @@ def program_listing(request):
|
||||
if not programs_config.enabled:
|
||||
raise Http404
|
||||
|
||||
meter = ProgramProgressMeter(request.user)
|
||||
meter = ProgramProgressMeter(request.site, request.user)
|
||||
|
||||
context = {
|
||||
'disable_courseware_js': True,
|
||||
@@ -48,7 +48,7 @@ def program_details(request, program_uuid):
|
||||
if not programs_config.enabled:
|
||||
raise Http404
|
||||
|
||||
meter = ProgramProgressMeter(request.user, uuid=program_uuid)
|
||||
meter = ProgramProgressMeter(request.site, request.user, uuid=program_uuid)
|
||||
program_data = meter.programs[0]
|
||||
|
||||
if not program_data:
|
||||
|
||||
@@ -42,6 +42,10 @@ def mobile_course_access(depth=0):
|
||||
except CoursewareAccessException as error:
|
||||
return Response(data=error.to_json(), status=status.HTTP_404_NOT_FOUND)
|
||||
except CourseAccessRedirect as error:
|
||||
# If the redirect contains information about the triggering AccessError,
|
||||
# return the information contained in the AccessError.
|
||||
if error.access_error is not None:
|
||||
return Response(data=error.access_error.to_json(), status=status.HTTP_404_NOT_FOUND)
|
||||
# Raise a 404 if the user does not have course access
|
||||
raise Http404
|
||||
return func(self, request, course=course, *args, **kwargs)
|
||||
|
||||
@@ -4,7 +4,7 @@ Milestone related tests for the mobile_api
|
||||
from django.conf import settings
|
||||
from mock import patch
|
||||
|
||||
from courseware.access_response import MilestoneError
|
||||
from courseware.access_response import MilestoneAccessError
|
||||
from courseware.tests.test_entrance_exam import add_entrance_exam_milestone, answer_entrance_exam_problem
|
||||
from openedx.core.djangolib.testing.utils import get_mock_request
|
||||
from util.milestones_helpers import add_prerequisite_course, fulfill_course_milestone
|
||||
@@ -136,4 +136,4 @@ class MobileAPIMilestonesMixin(object):
|
||||
self.api_response()
|
||||
else:
|
||||
response = self.api_response(expected_response_code=404)
|
||||
self.assertEqual(response.data, MilestoneError().to_json())
|
||||
self.assertEqual(response.data, MilestoneAccessError().to_json())
|
||||
|
||||
@@ -18,7 +18,7 @@ from certificates.api import generate_user_certificates
|
||||
from certificates.models import CertificateStatuses
|
||||
from certificates.tests.factories import GeneratedCertificateFactory
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.access_response import MilestoneError, StartDateError, VisibilityError
|
||||
from courseware.access_response import MilestoneAccessError, StartDateError, VisibilityError
|
||||
from lms.djangoapps.grades.tests.utils import mock_passing_grade
|
||||
from mobile_api.testutils import (
|
||||
MobileAPITestCase,
|
||||
@@ -155,7 +155,7 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest
|
||||
]
|
||||
|
||||
expected_error_codes = [
|
||||
MilestoneError().error_code, # 'unfulfilled_milestones'
|
||||
MilestoneAccessError().error_code, # 'unfulfilled_milestones'
|
||||
StartDateError(self.NEXT_WEEK).error_code, # 'course_not_started'
|
||||
VisibilityError().error_code, # 'not_visible_to_user'
|
||||
None,
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.contrib.auth.models import User
|
||||
from django.test.client import Client
|
||||
|
||||
from survey.models import SurveyForm
|
||||
from survey.utils import is_survey_required_for_course, must_answer_survey
|
||||
from survey.utils import is_survey_required_for_course, is_survey_required_and_unanswered
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
@@ -89,28 +89,28 @@ class SurveyModelsTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Assert that a new course which has a required survey but user has not answered it yet
|
||||
"""
|
||||
self.assertTrue(must_answer_survey(self.course, self.student))
|
||||
self.assertTrue(is_survey_required_and_unanswered(self.student, self.course))
|
||||
|
||||
temp_course = CourseFactory.create(
|
||||
course_survey_required=False
|
||||
)
|
||||
self.assertFalse(must_answer_survey(temp_course, self.student))
|
||||
self.assertFalse(is_survey_required_and_unanswered(self.student, temp_course))
|
||||
|
||||
temp_course = CourseFactory.create(
|
||||
course_survey_required=True,
|
||||
course_survey_name="NonExisting"
|
||||
)
|
||||
self.assertFalse(must_answer_survey(temp_course, self.student))
|
||||
self.assertFalse(is_survey_required_and_unanswered(self.student, temp_course))
|
||||
|
||||
def test_user_has_answered_required_survey(self):
|
||||
"""
|
||||
Assert that a new course which has a required survey and user has answers for it
|
||||
"""
|
||||
self.survey.save_user_answers(self.student, self.student_answers, None)
|
||||
self.assertFalse(must_answer_survey(self.course, self.student))
|
||||
self.assertFalse(is_survey_required_and_unanswered(self.student, self.course))
|
||||
|
||||
def test_staff_must_answer_survey(self):
|
||||
"""
|
||||
Assert that someone with staff level permissions does not have to answer the survey
|
||||
"""
|
||||
self.assertFalse(must_answer_survey(self.course, self.staff))
|
||||
self.assertFalse(is_survey_required_and_unanswered(self.staff, self.course))
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""
|
||||
Helper methods for Surveys
|
||||
Utilities for determining whether or not a survey needs to be completed.
|
||||
"""
|
||||
|
||||
from courseware.access import has_access
|
||||
from survey.models import SurveyAnswer, SurveyForm
|
||||
from survey.models import SurveyForm, SurveyAnswer
|
||||
|
||||
|
||||
def is_survey_required_for_course(course_descriptor):
|
||||
@@ -11,17 +10,19 @@ def is_survey_required_for_course(course_descriptor):
|
||||
Returns whether a Survey is required for this course
|
||||
"""
|
||||
|
||||
# check to see that the Survey name has been defined in the CourseDescriptor
|
||||
# and that the specified Survey exists
|
||||
# Check to see that the survey is required in the CourseDescriptor.
|
||||
if not getattr(course_descriptor, 'course_survey_required', False):
|
||||
return False
|
||||
|
||||
return course_descriptor.course_survey_required and \
|
||||
SurveyForm.get(course_descriptor.course_survey_name, throw_if_not_found=False)
|
||||
# Check that the specified Survey for the course exists.
|
||||
return SurveyForm.get(course_descriptor.course_survey_name, throw_if_not_found=False)
|
||||
|
||||
|
||||
def must_answer_survey(course_descriptor, user):
|
||||
def is_survey_required_and_unanswered(user, course_descriptor):
|
||||
"""
|
||||
Returns whether a user needs to answer a required survey
|
||||
Returns whether a user is required to answer the survey and has yet to do so.
|
||||
"""
|
||||
|
||||
if not is_survey_required_for_course(course_descriptor):
|
||||
return False
|
||||
|
||||
@@ -29,13 +30,13 @@ def must_answer_survey(course_descriptor, user):
|
||||
if user.is_anonymous():
|
||||
return False
|
||||
|
||||
# this will throw exception if not found, but a non existing survey name will
|
||||
# be trapped in the above is_survey_required_for_course() method
|
||||
survey = SurveyForm.get(course_descriptor.course_survey_name)
|
||||
|
||||
# course staff do not need to answer survey
|
||||
has_staff_access = has_access(user, 'staff', course_descriptor)
|
||||
if has_staff_access:
|
||||
return False
|
||||
|
||||
# survey is required and it exists, let's see if user has answered the survey
|
||||
# course staff do not need to answer survey
|
||||
survey = SurveyForm.get(course_descriptor.course_survey_name)
|
||||
answered_survey = SurveyAnswer.do_survey_answers_exist(survey, user)
|
||||
return not answered_survey and not has_staff_access
|
||||
if not answered_survey:
|
||||
return True
|
||||
|
||||
@@ -580,14 +580,6 @@ DATADOG.update(ENV_TOKENS.get("DATADOG", {}))
|
||||
if 'DATADOG_API' in AUTH_TOKENS:
|
||||
DATADOG['api_key'] = AUTH_TOKENS['DATADOG_API']
|
||||
|
||||
# Analytics dashboard server
|
||||
ANALYTICS_SERVER_URL = ENV_TOKENS.get("ANALYTICS_SERVER_URL")
|
||||
ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", "")
|
||||
|
||||
# Analytics data source
|
||||
ANALYTICS_DATA_URL = ENV_TOKENS.get("ANALYTICS_DATA_URL", ANALYTICS_DATA_URL)
|
||||
ANALYTICS_DATA_TOKEN = AUTH_TOKENS.get("ANALYTICS_DATA_TOKEN", ANALYTICS_DATA_TOKEN)
|
||||
|
||||
# Analytics Dashboard
|
||||
ANALYTICS_DASHBOARD_URL = ENV_TOKENS.get("ANALYTICS_DASHBOARD_URL", ANALYTICS_DASHBOARD_URL)
|
||||
ANALYTICS_DASHBOARD_NAME = ENV_TOKENS.get("ANALYTICS_DASHBOARD_NAME", PLATFORM_NAME + " Insights")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_API_KEY": "",
|
||||
"AWS_ACCESS_KEY_ID": "",
|
||||
"AWS_SECRET_ACCESS_KEY": "",
|
||||
"CC_PROCESSOR_NAME": "CyberSource2",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_SERVER_URL": "",
|
||||
"ANALYTICS_DASHBOARD_URL": "",
|
||||
"BOOK_URL": "",
|
||||
"BUGS_EMAIL": "bugs@example.com",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_API_KEY": "",
|
||||
"AWS_ACCESS_KEY_ID": "",
|
||||
"AWS_SECRET_ACCESS_KEY": "",
|
||||
"CC_PROCESSOR_NAME": "CyberSource2",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"ANALYTICS_SERVER_URL": "",
|
||||
"ANALYTICS_DASHBOARD_URL": "",
|
||||
"BOOK_URL": "",
|
||||
"BUGS_EMAIL": "bugs@example.com",
|
||||
|
||||
@@ -2846,9 +2846,7 @@ ADVANCED_SECURITY_CONFIG = {}
|
||||
SHIBBOLETH_DOMAIN_PREFIX = 'shib:'
|
||||
OPENID_DOMAIN_PREFIX = 'openid:'
|
||||
|
||||
### Analytics Data API + Dashboard (Insights) settings
|
||||
ANALYTICS_DATA_URL = ""
|
||||
ANALYTICS_DATA_TOKEN = ""
|
||||
### Analytics Dashboard (Insights) settings
|
||||
ANALYTICS_DASHBOARD_URL = ""
|
||||
ANALYTICS_DASHBOARD_NAME = PLATFORM_NAME + " Insights"
|
||||
|
||||
|
||||
@@ -234,11 +234,6 @@ FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True
|
||||
|
||||
PIPELINE_SASS_ARGUMENTS = '--debug-info'
|
||||
|
||||
########################## ANALYTICS TESTING ########################
|
||||
|
||||
ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
|
||||
ANALYTICS_API_KEY = ""
|
||||
|
||||
##### Segment ######
|
||||
|
||||
# If there's an environment variable set, grab it
|
||||
|
||||
@@ -43,9 +43,6 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
########################## ANALYTICS TESTING ########################
|
||||
|
||||
ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/"
|
||||
ANALYTICS_API_KEY = ""
|
||||
|
||||
# Set this to the dashboard URL in order to display the link from the
|
||||
# dashboard to the Analytics Dashboard.
|
||||
ANALYTICS_DASHBOARD_URL = None
|
||||
|
||||
@@ -4,23 +4,27 @@
|
||||
// Please list the ticket number of the experiment
|
||||
|
||||
// --------------------
|
||||
// LEARNER-1312 Track Selection V2
|
||||
/* This css was added as part of the LEARNER-1312 experiment */
|
||||
// LEARNER-1726 Track Selection V3
|
||||
/* This css was added as part of the LEARNER-1726 experiment */
|
||||
.v2.register-choice {
|
||||
margin: 0 2% 20px 0 !important
|
||||
}
|
||||
|
||||
.v2.register-choice-certificate .list-actions {
|
||||
text-align: left !important;
|
||||
}
|
||||
.v2.register-choice-donate .list-actions {
|
||||
|
||||
.v2.register-choice-continue .list-actions {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.v2.register-choice-donate .action-select {
|
||||
|
||||
.v2.register-choice-continue .action-select {
|
||||
display: inline-block !important;
|
||||
list-style-type: none !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.v2.register-choice-donate .donation-link {
|
||||
|
||||
.v2.register-choice-continue .continue-link {
|
||||
display: inline-block !important;
|
||||
padding: 10px 15px !important;
|
||||
border-radius: 3px !important;
|
||||
@@ -30,219 +34,360 @@
|
||||
text-align: center !important;
|
||||
color: #D7548E !important;
|
||||
float: left !important;
|
||||
font-size: 15px;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit {
|
||||
height: 250px !important;
|
||||
|
||||
.v2.register-choice-v2-donate {
|
||||
height: 300px;
|
||||
background: none !important;
|
||||
border-top-color: grey !important;
|
||||
border-top-width: 1px !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit .list-actions {
|
||||
|
||||
@media screen and (min-width: 375px) {
|
||||
.v2.register-choice-v2-donate {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
.v2.register-choice-v2-donate .list-actions {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit .list-actions input {
|
||||
|
||||
.v2.register-choice-v2-donate .list-actions a {
|
||||
background: transparent !important;
|
||||
color: #0075b4 !important;
|
||||
box-shadow: none !important;
|
||||
text-decoration: underline !important;
|
||||
border: none !important;
|
||||
white-space: normal;
|
||||
}
|
||||
.v2.register-choice-v2-audit .wrapper-copy-inline {
|
||||
|
||||
.v2.register-choice-v2-donate .wrapper-copy-inline {
|
||||
height: 70px !important;
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit .wrapper-copy {
|
||||
|
||||
.v2.register-choice-v2-donate .wrapper-copy {
|
||||
width: 70% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.v2.page-header {
|
||||
padding-bottom: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v2 img {
|
||||
margin-top: 20px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.v2 .donation-link {
|
||||
|
||||
.v2 .continue-link {
|
||||
font-weight: bold !important;
|
||||
}
|
||||
@media (min-width: 320px) {
|
||||
.v2.register-choice-certificate,
|
||||
.v2.register-choice-donate,
|
||||
.v2.register-choice-view {
|
||||
width: 100%;
|
||||
}
|
||||
.v2 .wrapper-copy-inline {
|
||||
max-height: 115px;
|
||||
}
|
||||
.v2.register-choice-v2-audit .wrapper-copy-inline {
|
||||
display: block !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit .copy-inline {
|
||||
width: 100% !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit .list-actions {
|
||||
width: 100% !important;
|
||||
margin-top: 20px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.v2 .wrapper-copy-inline .wrapper-copy {
|
||||
width: 100% !important;
|
||||
}
|
||||
.v2 .donation-link, .v2 input {
|
||||
width: 100% !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
.v2 img {
|
||||
display: none;
|
||||
|
||||
.v2.register-choice-certificate,
|
||||
.v2.register-choice-continue,
|
||||
.v2.register-choice-view {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.v2.register-choice-continue {
|
||||
border-color: #D7548E !important;
|
||||
}
|
||||
|
||||
.v2 .wrapper-copy-inline {
|
||||
max-height: 115px;
|
||||
}
|
||||
|
||||
.v2.register-choice-v2-donate .wrapper-copy-inline {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.v2.register-choice-v2-donate .copy-inline {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.v2.register-choice-v2-donate .list-actions {
|
||||
width: 100% !important;
|
||||
margin-top: 20px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.v2 .wrapper-copy-inline .wrapper-copy {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.v2 input, .v2 a {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
.v2 button {
|
||||
background-color: rgb(0, 103, 0);
|
||||
border-color: rgb(0, 103, 0);
|
||||
border-radius: 2px;
|
||||
box-shadow: rgb(0, 77, 0) 0px 2px 1px 0px;
|
||||
cursor: pointer;
|
||||
font-family: "Open Sans";
|
||||
height: auto;
|
||||
margin-right: 4px;
|
||||
margin-top: 0px;
|
||||
padding: 10px 15px;
|
||||
width: initial;
|
||||
background-image: none !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: #009b00 !important;
|
||||
border-color: #009b00;
|
||||
box-shadow: #004d00 0px 2px 1px 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.savings-message {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
@media screen and (min-width: 375px) {
|
||||
.savings-message {
|
||||
font-size: 13px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.v2 .continue-link, .v2 input, .v2 button, .v2 a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.v2 img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2 .deco-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2 .visual-reference {
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
@media (min-width: 420px) {
|
||||
.v2 button {
|
||||
height: 45px;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.v2.register-choice-certificate,
|
||||
.v2.register-choice-donate {
|
||||
width: 48% !important;
|
||||
.v2.register-choice-continue,
|
||||
.v2.deco-divider {
|
||||
width: 46.5% !important;
|
||||
display: inline-block;
|
||||
min-height: 250px;
|
||||
min-height: 270px;
|
||||
}
|
||||
.v2.register-choice-v2-audit .wrapper-copy-inline {
|
||||
|
||||
.v2.register-choice-v2-donate .wrapper-copy-inline {
|
||||
display: flex !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit .copy-inline {
|
||||
|
||||
.v2.register-choice-v2-donate .copy-inline {
|
||||
width: 40% !important;
|
||||
}
|
||||
.v2.register-choice-v2-audit .list-actions {
|
||||
|
||||
.v2.register-choice-v2-donate .list-actions {
|
||||
margin-top: 0 !important;
|
||||
text-align: right !important;
|
||||
}
|
||||
|
||||
.v2 .wrapper-copy-inline .wrapper-copy {
|
||||
width: 58% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.v2 .donation-link, .v2 input {
|
||||
|
||||
.v2 input, .v2 a {
|
||||
font-size: 15px !important;
|
||||
width: 55% !important;
|
||||
}
|
||||
|
||||
.v2 .continue-link, .v2.register-choice-certificate button, .v2.register-choice-certificate input {
|
||||
margin-top: 20px;
|
||||
width: initial;
|
||||
}
|
||||
|
||||
.v2.register-choice-v2-donate a {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.v2.register-choice-view {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.v2 img {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.v2.register-choice {
|
||||
margin: 0 2% 20px 0;
|
||||
}
|
||||
|
||||
.v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.v2.register-choice-view .wrapper-copy-inline .wrapper-copy {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.v2.register-choice {
|
||||
padding: 15px !important;
|
||||
}
|
||||
|
||||
.v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.v2.register-choice {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.v2.register-choice.register-choice-view {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.v2.register-choice .list-actions:last-child {
|
||||
float: left;
|
||||
width: 100%;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.v2.register-choice .action-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.v2 .continue-link:hover,
|
||||
.v2 .continue-link:focus {
|
||||
background-color: #D7548E !important;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.v2 .continue-link:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v2 .copy li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.v2.register-choice .copy-inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.v2 .register-choice-view {
|
||||
border-color: #2991c3 !important;
|
||||
}
|
||||
|
||||
.v2 .visual-reference {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.v2 .wrapper-copy-inline .wrapper-copy ul {
|
||||
margin-top: 0px;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.v2 .img-certificate {
|
||||
border: 2px solid #009b00 !important;
|
||||
float: right;
|
||||
height: 120px;
|
||||
width: auto;
|
||||
margin-top: 0 !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2 .img-donate {
|
||||
margin-top: 0;
|
||||
float: right;
|
||||
border: 2px solid #D7548E !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v2 .img-view {
|
||||
border: 2px solid #2991c3 !important;
|
||||
}
|
||||
|
||||
.v2.register-choice .title {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.v2.register-choice.register-choice-view .action-select {
|
||||
border: 1px solid transparent !important;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.v2.register-choice.register-choice-view .action-select button {
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
.v2.register-choice.register-choice-view .action-select:hover {
|
||||
border: 1px solid #0075b4 !important;
|
||||
}
|
||||
|
||||
.v2.deco-divider {
|
||||
width: 3% !important;
|
||||
box-sizing: border-box;
|
||||
float: left;
|
||||
display: inline-block;
|
||||
height: 400px;
|
||||
height: 250px;
|
||||
margin: 0px 0 40px 0 !important;
|
||||
border-left: 4px solid #f5f5f5 !important;
|
||||
border-top: none !important;
|
||||
border-left: 4px solid #f5f5f5 !important; border-top:none !important;
|
||||
|
||||
.copy {
|
||||
position: absolute;
|
||||
top: 110px !important;
|
||||
left: calc(50% - 40px) !important;
|
||||
margin-left: 20px;
|
||||
background: white;
|
||||
text-align: center;
|
||||
color: #474747;
|
||||
width: 10px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 320px) {
|
||||
.v2 .visual-reference {
|
||||
width: 38%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@media (min-width: 320px) {
|
||||
.v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 320px) {
|
||||
.v2.register-choice-view .wrapper-copy-inline .wrapper-copy {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media (min-width: 320px) {
|
||||
.v2.register-choice {
|
||||
padding: 15px !important;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.v2.register-choice {
|
||||
padding: 20px !important;
|
||||
}
|
||||
.v2.register-choice.register-choice-view {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 769px) {
|
||||
.v2.register-choice .list-actions:last-child {
|
||||
float: left;
|
||||
width: 100%;
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 769px) {
|
||||
.v2.register-choice .action-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
.v2 .donation-link:hover,
|
||||
.v2 .donation-link:focus {
|
||||
background-color: #D7548E !important;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
.v2 .donation-link:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.v2 .copy li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.v2.register-choice .copy-inline {
|
||||
width: 100%;
|
||||
}
|
||||
.v2.register-choice-donate {
|
||||
border-color: #D7548E !important;
|
||||
}
|
||||
.v2 .register-choice-view {
|
||||
border-color: #2991c3 !important;
|
||||
}
|
||||
.v2 .visual-reference {
|
||||
vertical-align: top;
|
||||
}
|
||||
.v2 .wrapper-copy-inline .wrapper-copy ul {
|
||||
margin-top: 0px;
|
||||
padding-left: 30px;
|
||||
}
|
||||
.v2 .img-certificate {
|
||||
border: 2px solid #009b00 !important;
|
||||
}
|
||||
.v2 .img-donate {
|
||||
border: 2px solid #D7548E !important;
|
||||
}
|
||||
.v2 .img-view {
|
||||
border: 2px solid #2991c3 !important;
|
||||
}
|
||||
.v2.register-choice .title {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.v2.register-choice.register-choice-view .action-select {
|
||||
border: 1px solid transparent !important;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.v2.register-choice.register-choice-view .action-select input {
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
.v2.register-choice.register-choice-view .action-select:hover {
|
||||
border: 1px solid #0075b4 !important;
|
||||
}
|
||||
@media (min-width: 835px) {
|
||||
.v2.register-choice-certificate,
|
||||
.v2.register-choice-continue,
|
||||
.v2.deco-divider {
|
||||
display: none !important;
|
||||
min-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.v2 .continue-link {
|
||||
width: 55%;
|
||||
}
|
||||
.v2.deco-divider .copy {
|
||||
margin-left: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1096px) {
|
||||
.v2.register-choice-certificate,
|
||||
.v2.register-choice-continue,
|
||||
.v2.deco-divider {
|
||||
min-height: 260px;
|
||||
}
|
||||
.v2 .img-certificate, .v2 .img-donate {
|
||||
margin-top: 10px;
|
||||
display: initial;
|
||||
}
|
||||
.v2 .continue-link, .v2.register-choice-certificate button,
|
||||
.v2.register-choice-certificate input {
|
||||
margin-top: -22px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,69 @@
|
||||
// ------------------------------
|
||||
// Styling for files located in the openedx/features repository.
|
||||
|
||||
// Course call to action message
|
||||
.course-message {
|
||||
.message-author {
|
||||
display: inline-block;
|
||||
width: 70px;
|
||||
border-radius: $baseline*7/4;
|
||||
border: 1px solid $lms-border-color;
|
||||
|
||||
@media (max-width: $grid-breakpoints-md) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
position: relative;
|
||||
border: 1px solid $lms-border-color;
|
||||
margin: 0 $baseline $baseline/2;
|
||||
padding: $baseline/2 $baseline;
|
||||
border-radius: $baseline/4;
|
||||
|
||||
@media (max-width: $grid-breakpoints-md) {
|
||||
width: 100%;
|
||||
margin: $baseline 0;
|
||||
}
|
||||
|
||||
&:after, &:before {
|
||||
@include left(0);
|
||||
bottom: 35%;
|
||||
border: solid transparent;
|
||||
height: 0;
|
||||
width: 0;
|
||||
content: " ";
|
||||
position: absolute;
|
||||
|
||||
@media (max-width: $grid-breakpoints-md) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
@include border-right-color($white);
|
||||
@include margin-left($baseline*-1+1);
|
||||
border-width: $baseline/2;
|
||||
}
|
||||
|
||||
&:before {
|
||||
@include margin-left($baseline*-1);
|
||||
@include border-right-color($lms-border-color);
|
||||
border-width: $baseline/2;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
font-weight: $font-semibold;
|
||||
margin-bottom: $baseline/4;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: $font-semibold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Welcome message
|
||||
.welcome-message {
|
||||
border: solid 1px $lms-border-color;
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
// ----------------------------
|
||||
$lms-max-width: 1180px !default;
|
||||
|
||||
$grid-breakpoints-sm: 576px !default;
|
||||
$grid-breakpoints-md: 768px !default;
|
||||
$grid-breakpoints-lg: 992px !default;
|
||||
|
||||
// ----------------------------
|
||||
// #COLORS
|
||||
// ----------------------------
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
from openedx.core.djangoapps.util.user_messages import user_messages
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
%>
|
||||
|
||||
<%
|
||||
banner_messages = list(user_messages(request))
|
||||
banner_messages = list(PageLevelMessages.user_messages(request))
|
||||
%>
|
||||
|
||||
% if banner_messages:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
## mako
|
||||
|
||||
## Override the default styles_version to use Bootstrap
|
||||
<%! main_css = "css/bootstrap/lms-main.css" %>
|
||||
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="/main.html" />
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ class BookmarksListViewTests(BookmarksViewsTestsBase):
|
||||
self.assertEqual(response.data['developer_message'], u'Parameter usage_id not provided.')
|
||||
|
||||
# Send empty data dictionary.
|
||||
with self.assertNumQueries(8): # No queries for bookmark table.
|
||||
with self.assertNumQueries(7): # No queries for bookmark table.
|
||||
response = self.send_post(
|
||||
client=self.client,
|
||||
url=reverse('bookmarks'),
|
||||
|
||||
@@ -50,6 +50,7 @@ class Command(BaseCommand):
|
||||
site_config = getattr(site, 'configuration', None)
|
||||
if site_config is None or not site_config.get_value('COURSE_CATALOG_API_URL'):
|
||||
logger.info('Skipping site {domain}. No configuration.'.format(domain=site.domain))
|
||||
cache.set(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), [], None)
|
||||
continue
|
||||
|
||||
client = create_catalog_api_client(user, site=site)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Tests covering utilities for integrating with the catalog service."""
|
||||
# pylint: disable=missing-docstring
|
||||
import copy
|
||||
import uuid
|
||||
|
||||
import ddt
|
||||
import mock
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, PROGRAM_UUIDS_CACHE_KEY
|
||||
from openedx.core.djangoapps.catalog.models import CatalogIntegration
|
||||
@@ -19,8 +19,8 @@ from openedx.core.djangoapps.catalog.utils import (
|
||||
get_programs,
|
||||
get_programs_with_type
|
||||
)
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
UTILS_MODULE = 'openedx.core.djangoapps.catalog.utils'
|
||||
User = get_user_model() # pylint: disable=invalid-name
|
||||
@@ -32,6 +32,10 @@ User = get_user_model() # pylint: disable=invalid-name
|
||||
class TestGetPrograms(CacheIsolationTestCase):
|
||||
ENABLED_CACHES = ['default']
|
||||
|
||||
def setUp(self):
|
||||
super(TestGetPrograms, self).setUp()
|
||||
self.site = SiteFactory()
|
||||
|
||||
def test_get_many(self, mock_warning, mock_info):
|
||||
programs = ProgramFactory.create_batch(3)
|
||||
|
||||
@@ -43,7 +47,7 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
|
||||
# When called before UUIDs are cached, the function should return an
|
||||
# empty list and log a warning.
|
||||
self.assertEqual(get_programs(), [])
|
||||
self.assertEqual(get_programs(self.site), [])
|
||||
mock_warning.assert_called_once_with('Failed to get program UUIDs from the cache.')
|
||||
mock_warning.reset_mock()
|
||||
|
||||
@@ -54,7 +58,7 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
None
|
||||
)
|
||||
|
||||
actual_programs = get_programs()
|
||||
actual_programs = get_programs(self.site)
|
||||
|
||||
# The 2 cached programs should be returned while info and warning
|
||||
# messages should be logged for the missing one.
|
||||
@@ -82,7 +86,7 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
}
|
||||
cache.set_many(all_programs, None)
|
||||
|
||||
actual_programs = get_programs()
|
||||
actual_programs = get_programs(self.site)
|
||||
|
||||
# All 3 programs should be returned.
|
||||
self.assertEqual(
|
||||
@@ -116,7 +120,7 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
mock_cache.get.return_value = [program['uuid'] for program in programs]
|
||||
mock_cache.get_many.side_effect = fake_get_many
|
||||
|
||||
actual_programs = get_programs()
|
||||
actual_programs = get_programs(self.site)
|
||||
|
||||
# All 3 cached programs should be returned. An info message should be
|
||||
# logged about the one that was initially missing, but the code should
|
||||
@@ -136,7 +140,7 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
expected_program = ProgramFactory()
|
||||
expected_uuid = expected_program['uuid']
|
||||
|
||||
self.assertEqual(get_programs(uuid=expected_uuid), None)
|
||||
self.assertEqual(get_programs(self.site, uuid=expected_uuid), None)
|
||||
mock_warning.assert_called_once_with(
|
||||
'Failed to get details for program {uuid} from the cache.'.format(uuid=expected_uuid)
|
||||
)
|
||||
@@ -148,7 +152,7 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
None
|
||||
)
|
||||
|
||||
actual_program = get_programs(uuid=expected_uuid)
|
||||
actual_program = get_programs(self.site, uuid=expected_uuid)
|
||||
self.assertEqual(actual_program, expected_program)
|
||||
self.assertFalse(mock_warning.called)
|
||||
|
||||
@@ -156,6 +160,9 @@ class TestGetPrograms(CacheIsolationTestCase):
|
||||
@skip_unless_lms
|
||||
@ddt.ddt
|
||||
class TestGetProgramsWithType(TestCase):
|
||||
def setUp(self):
|
||||
super(TestGetProgramsWithType, self).setUp()
|
||||
self.site = SiteFactory()
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.get_programs')
|
||||
@mock.patch(UTILS_MODULE + '.get_program_types')
|
||||
@@ -176,7 +183,7 @@ class TestGetProgramsWithType(TestCase):
|
||||
mock_get_programs.return_value = programs
|
||||
mock_get_program_types.return_value = program_types
|
||||
|
||||
actual = get_programs_with_type()
|
||||
actual = get_programs_with_type(self.site)
|
||||
self.assertEqual(actual, programs_with_program_type)
|
||||
|
||||
@ddt.data(False, True)
|
||||
@@ -202,7 +209,7 @@ class TestGetProgramsWithType(TestCase):
|
||||
mock_get_programs.return_value = programs
|
||||
mock_get_program_types.return_value = program_types
|
||||
|
||||
actual = get_programs_with_type(include_hidden=include_hidden)
|
||||
actual = get_programs_with_type(self.site, include_hidden=include_hidden)
|
||||
self.assertEqual(actual, programs_with_program_type)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from openedx.core.djangoapps.catalog.cache import (
|
||||
SITE_PROGRAM_UUIDS_CACHE_KEY_TPL
|
||||
)
|
||||
from openedx.core.djangoapps.catalog.models import CatalogIntegration
|
||||
from openedx.core.djangoapps.theming.helpers import get_current_site
|
||||
from openedx.core.lib.edx_api_utils import get_edx_api_data
|
||||
from openedx.core.lib.token_utils import JwtBuilder
|
||||
|
||||
@@ -35,11 +34,14 @@ def create_catalog_api_client(user, site=None):
|
||||
return EdxRestApiClient(url, jwt=jwt)
|
||||
|
||||
|
||||
def get_programs(uuid=None):
|
||||
def get_programs(site, uuid=None):
|
||||
"""Read programs from the cache.
|
||||
|
||||
The cache is populated by a management command, cache_programs.
|
||||
|
||||
Arguments:
|
||||
site (Site): django.contrib.sites.models object
|
||||
|
||||
Keyword Arguments:
|
||||
uuid (string): UUID identifying a specific program to read from the cache.
|
||||
|
||||
@@ -56,7 +58,7 @@ def get_programs(uuid=None):
|
||||
|
||||
return program
|
||||
if waffle.switch_is_active('get-multitenant-programs'):
|
||||
uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=get_current_site().domain), [])
|
||||
uuids = cache.get(SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=site.domain), [])
|
||||
else:
|
||||
uuids = cache.get(PROGRAM_UUIDS_CACHE_KEY, [])
|
||||
if not uuids:
|
||||
@@ -121,13 +123,16 @@ def get_program_types(name=None):
|
||||
return []
|
||||
|
||||
|
||||
def get_programs_with_type(include_hidden=True):
|
||||
def get_programs_with_type(site, include_hidden=True):
|
||||
"""
|
||||
Return the list of programs. You can filter the types of programs returned by using the optional
|
||||
include_hidden parameter. By default hidden programs will be included.
|
||||
|
||||
The program dict is updated with the fully serialized program type.
|
||||
|
||||
Arguments:
|
||||
site (Site): django.contrib.sites.models object
|
||||
|
||||
Keyword Arguments:
|
||||
include_hidden (bool): whether to include hidden programs
|
||||
|
||||
@@ -135,7 +140,7 @@ def get_programs_with_type(include_hidden=True):
|
||||
list of dict, representing the active programs.
|
||||
"""
|
||||
programs_with_type = []
|
||||
programs = get_programs()
|
||||
programs = get_programs(site)
|
||||
|
||||
if programs:
|
||||
program_types = {program_type['name']: program_type for program_type in get_program_types()}
|
||||
|
||||
@@ -7,12 +7,7 @@ from django.http import HttpResponseNotFound
|
||||
from django.utils.translation import ugettext as _
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from mako.exceptions import TopLevelLookupException
|
||||
from openedx.core.djangoapps.util.user_messages import (
|
||||
register_error_message,
|
||||
register_info_message,
|
||||
register_success_message,
|
||||
register_warning_message,
|
||||
)
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
|
||||
|
||||
def show_reference_template(request, template):
|
||||
@@ -28,23 +23,34 @@ def show_reference_template(request, template):
|
||||
e.g. /template/ux/reference/index.html?name=Foo
|
||||
"""
|
||||
try:
|
||||
uses_bootstrap = u'/bootstrap/' in request.path
|
||||
uses_pattern_library = u'/pattern-library/' in request.path
|
||||
is_v1 = u'/v1/' in request.path
|
||||
uses_bootstrap = not uses_pattern_library and not is_v1
|
||||
context = {
|
||||
"disable_courseware_js": not is_v1,
|
||||
"uses_pattern_library": uses_pattern_library,
|
||||
"uses_bootstrap": uses_bootstrap,
|
||||
'request': request,
|
||||
'disable_courseware_js': not is_v1,
|
||||
'uses_pattern_library': uses_pattern_library,
|
||||
'uses_bootstrap': uses_bootstrap,
|
||||
}
|
||||
context.update(request.GET.dict())
|
||||
|
||||
# Support dynamic rendering of messages
|
||||
if request.GET.get('alert'):
|
||||
register_info_message(request, request.GET.get('alert'))
|
||||
if request.GET.get('success'):
|
||||
register_success_message(request, request.GET.get('success'))
|
||||
if request.GET.get('warning'):
|
||||
register_warning_message(request, request.GET.get('warning'))
|
||||
if request.GET.get('error'):
|
||||
register_error_message(request, request.GET.get('error'))
|
||||
|
||||
# Add some messages to the course skeleton pages
|
||||
if u'course-skeleton.html' in request.path:
|
||||
register_info_message(request, _('This is a test message'))
|
||||
register_success_message(request, _('This is a success message'))
|
||||
register_warning_message(request, _('This is a test warning'))
|
||||
register_error_message(request, _('This is a test error'))
|
||||
PageLevelMessages.register_info_message(request, _('This is a test message'))
|
||||
PageLevelMessages.register_success_message(request, _('This is a success message'))
|
||||
PageLevelMessages.register_warning_message(request, _('This is a test warning'))
|
||||
PageLevelMessages.register_error_message(request, _('This is a test error'))
|
||||
|
||||
return render_to_response(template, context)
|
||||
except TopLevelLookupException:
|
||||
return HttpResponseNotFound("Couldn't find template {template}".format(template=template))
|
||||
return HttpResponseNotFound('Missing template {template}'.format(template=template))
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""Management command for backpopulating missing program credentials."""
|
||||
from collections import namedtuple
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management import BaseCommand
|
||||
from django.db.models import Q
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from certificates.models import GeneratedCertificate, CertificateStatuses # pylint: disable=import-error
|
||||
from certificates.models import CertificateStatuses, GeneratedCertificate # pylint: disable=import-error
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.catalog.utils import get_programs
|
||||
from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates
|
||||
|
||||
|
||||
# TODO: Log to console, even with debug mode disabled?
|
||||
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
||||
CourseRun = namedtuple('CourseRun', ['key', 'type'])
|
||||
@@ -73,7 +72,11 @@ class Command(BaseCommand):
|
||||
|
||||
def _load_course_runs(self):
|
||||
"""Find all course runs which are part of a program."""
|
||||
programs = get_programs()
|
||||
programs = []
|
||||
for site in Site.objects.all():
|
||||
logger.info('Loading programs from the catalog for site %s.', site.domain)
|
||||
programs.extend(get_programs(site))
|
||||
|
||||
self.course_runs = self._flatten(programs)
|
||||
|
||||
def _flatten(self, programs):
|
||||
|
||||
@@ -5,6 +5,7 @@ from celery import task
|
||||
from celery.utils.log import get_task_logger # pylint: disable=no-name-in-module, import-error
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from edx_rest_api_client import exceptions
|
||||
from edx_rest_api_client.client import EdxRestApiClient
|
||||
@@ -55,18 +56,19 @@ def get_api_client(api_config, student):
|
||||
return EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
|
||||
|
||||
|
||||
def get_completed_programs(student):
|
||||
def get_completed_programs(site, student):
|
||||
"""
|
||||
Given a set of completed courses, determine which programs are completed.
|
||||
|
||||
Args:
|
||||
site (Site): Site for which data should be retrieved.
|
||||
student (User): Representing the student whose completed programs to check for.
|
||||
|
||||
Returns:
|
||||
list of program UUIDs
|
||||
|
||||
"""
|
||||
meter = ProgramProgressMeter(student)
|
||||
meter = ProgramProgressMeter(site, student)
|
||||
return meter.completed_programs
|
||||
|
||||
|
||||
@@ -80,7 +82,7 @@ def get_certified_programs(student):
|
||||
User object representing the student
|
||||
|
||||
Returns:
|
||||
UUIDs of the programs for which the student has been awarded a certificate
|
||||
str[]: UUIDs of the programs for which the student has been awarded a certificate
|
||||
|
||||
"""
|
||||
certified_programs = []
|
||||
@@ -129,8 +131,7 @@ def award_program_certificates(self, username):
|
||||
student.
|
||||
|
||||
Args:
|
||||
username:
|
||||
The username of the student
|
||||
username (str): The username of the student
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -158,16 +159,16 @@ def award_program_certificates(self, username):
|
||||
LOGGER.exception('Task award_program_certificates was called with invalid username %s', username)
|
||||
# Don't retry for this case - just conclude the task.
|
||||
return
|
||||
|
||||
program_uuids = get_completed_programs(student)
|
||||
program_uuids = []
|
||||
for site in Site.objects.all():
|
||||
program_uuids.extend(get_completed_programs(site, student))
|
||||
if not program_uuids:
|
||||
# No reason to continue beyond this point unless/until this
|
||||
# task gets updated to support revocation of program certs.
|
||||
LOGGER.info('Task award_program_certificates was called for user %s with no completed programs', username)
|
||||
return
|
||||
|
||||
# Determine which program certificates the user has already been
|
||||
# awarded, if any.
|
||||
# Determine which program certificates the user has already been awarded, if any.
|
||||
existing_program_uuids = get_certified_programs(student)
|
||||
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
|
||||
@@ -16,6 +16,7 @@ from edx_rest_api_client.client import EdxRestApiClient
|
||||
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
|
||||
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
|
||||
from openedx.core.djangoapps.programs.tasks.v1 import tasks
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
@@ -130,6 +131,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
super(AwardProgramCertificatesTestCase, self).setUp()
|
||||
self.create_credentials_config()
|
||||
self.student = UserFactory.create(username='test-student')
|
||||
self.site = SiteFactory()
|
||||
|
||||
self.catalog_integration = self.create_catalog_integration()
|
||||
ClientFactory.create(name='credentials')
|
||||
@@ -146,7 +148,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
programs.
|
||||
"""
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
mock_get_completed_programs.assert_called_once_with(self.student)
|
||||
mock_get_completed_programs.assert_called(self.site, self.student)
|
||||
|
||||
@ddt.data(
|
||||
([1], [2, 3]),
|
||||
@@ -282,7 +284,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
"""
|
||||
mock_get_completed_programs.side_effect = self._make_side_effect([Exception('boom'), None])
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
self.assertEqual(mock_get_completed_programs.call_count, 2)
|
||||
self.assertEqual(mock_get_completed_programs.call_count, 3)
|
||||
|
||||
def test_retry_on_credentials_api_errors(
|
||||
self,
|
||||
|
||||
@@ -10,8 +10,8 @@ from student.tests.factories import UserFactory
|
||||
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
|
||||
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms, get_mock_request
|
||||
|
||||
TEST_USERNAME = 'test-user'
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from openedx.core.djangoapps.programs.utils import (
|
||||
ProgramMarketingDataExtender,
|
||||
get_certificates,
|
||||
)
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from student.tests.factories import AnonymousUserFactory, UserFactory, CourseEnrollmentFactory
|
||||
from util.date_utils import strftime_localized
|
||||
@@ -55,6 +56,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
super(TestProgramProgressMeter, self).setUp()
|
||||
|
||||
self.user = UserFactory()
|
||||
self.site = SiteFactory()
|
||||
|
||||
def _create_enrollments(self, *course_run_ids):
|
||||
"""Variadic helper used to create course run enrollments."""
|
||||
@@ -92,7 +94,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
data = [ProgramFactory()]
|
||||
mock_get_programs.return_value = data
|
||||
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
self.assertEqual(meter.engaged_programs, [])
|
||||
self._assert_progress(meter)
|
||||
@@ -104,7 +106,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
course_run_id = generate_course_run_key()
|
||||
self._create_enrollments(course_run_id)
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
self.assertEqual(meter.engaged_programs, [])
|
||||
self._assert_progress(meter)
|
||||
@@ -129,7 +131,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
mock_get_programs.return_value = data
|
||||
|
||||
self._create_enrollments(course_run_key)
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
self._attach_detail_url(data)
|
||||
program = data[0]
|
||||
@@ -159,7 +161,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
self._create_enrollments(course_run_key)
|
||||
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
program = data[0]
|
||||
expected = [
|
||||
@@ -195,7 +197,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
mode=CourseMode.NO_ID_PROFESSIONAL_MODE
|
||||
)
|
||||
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
program = data[0]
|
||||
expected = [
|
||||
@@ -236,7 +238,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit')
|
||||
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
program = data[0]
|
||||
expected = [
|
||||
@@ -278,7 +280,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
# The creation time of the enrollments matters to the test. We want
|
||||
# the first_course_run_key to represent the newest enrollment.
|
||||
self._create_enrollments(older_course_run_key, newer_course_run_key)
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
self._attach_detail_url(data)
|
||||
programs = data[:2]
|
||||
@@ -323,7 +325,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
# Enrollment for the shared course run created last (most recently).
|
||||
self._create_enrollments(solo_course_run_key, shared_course_run_key)
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
self._attach_detail_url(data)
|
||||
programs = data[:3]
|
||||
@@ -354,13 +356,13 @@ class TestProgramProgressMeter(TestCase):
|
||||
mock_get_programs.return_value = data
|
||||
|
||||
# No enrollments, no programs in progress.
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(meter)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
|
||||
# One enrollment, one program in progress.
|
||||
self._create_enrollments(first_course_run_key)
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
program, program_uuid = data[0], data[0]['uuid']
|
||||
self._assert_progress(
|
||||
meter,
|
||||
@@ -370,7 +372,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
# Two enrollments, all courses in progress.
|
||||
self._create_enrollments(second_course_run_key)
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(
|
||||
meter,
|
||||
ProgressFactory(uuid=program_uuid, in_progress=2)
|
||||
@@ -381,7 +383,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
mock_completed_course_runs.return_value = [
|
||||
{'course_run_id': first_course_run_key, 'type': MODES.verified},
|
||||
]
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(
|
||||
meter,
|
||||
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
|
||||
@@ -393,7 +395,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
{'course_run_id': first_course_run_key, 'type': MODES.verified},
|
||||
{'course_run_id': second_course_run_key, 'type': MODES.honor},
|
||||
]
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(
|
||||
meter,
|
||||
ProgressFactory(uuid=program_uuid, completed=1, in_progress=1)
|
||||
@@ -405,7 +407,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
{'course_run_id': first_course_run_key, 'type': MODES.verified},
|
||||
{'course_run_id': second_course_run_key, 'type': MODES.verified},
|
||||
]
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self._assert_progress(
|
||||
meter,
|
||||
ProgressFactory(uuid=program_uuid, completed=2)
|
||||
@@ -436,7 +438,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
mock_completed_course_runs.return_value = [
|
||||
{'course_run_id': course_run_key, 'type': MODES.honor},
|
||||
]
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
|
||||
program, program_uuid = data[0], data[0]['uuid']
|
||||
self._assert_progress(
|
||||
@@ -449,7 +451,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
"""Verify that programs with no courses do not count as completed."""
|
||||
program = ProgramFactory()
|
||||
program['courses'] = []
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
program_complete = meter._is_program_complete(program)
|
||||
self.assertFalse(program_complete)
|
||||
|
||||
@@ -469,7 +471,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
course_run_keys.append(course_run['key'])
|
||||
|
||||
# Verify that no programs are complete.
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
|
||||
# Complete all programs.
|
||||
@@ -480,7 +482,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
]
|
||||
|
||||
# Verify that all programs are complete.
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, program_uuids)
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.certificate_api.get_certificates_for_user')
|
||||
@@ -494,7 +496,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
self._make_certificate_result(status='unknown', course_key='unknown-course'),
|
||||
]
|
||||
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(
|
||||
meter.completed_course_runs,
|
||||
[
|
||||
@@ -517,7 +519,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
mock_get_programs.return_value = [program]
|
||||
|
||||
# Verify that the test program is not complete.
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, [])
|
||||
|
||||
# Grant a 'no-id-professional' certificate for one of the course runs,
|
||||
@@ -527,7 +529,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
]
|
||||
|
||||
# Verify that the program is complete.
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
self.assertEqual(meter.completed_programs, [program['uuid']])
|
||||
|
||||
@mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock)
|
||||
@@ -543,7 +545,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
program = ProgramFactory(courses=[course])
|
||||
mock_get_programs.return_value = [program]
|
||||
self._create_enrollments(course_run_key)
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
meter = ProgramProgressMeter(self.site, self.user)
|
||||
mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': 'verified'}]
|
||||
self.assertEqual(meter._is_course_complete(course), True)
|
||||
|
||||
|
||||
@@ -70,7 +70,8 @@ class ProgramProgressMeter(object):
|
||||
will only inspect this one program, not all programs the user may be
|
||||
engaged with.
|
||||
"""
|
||||
def __init__(self, user, enrollments=None, uuid=None):
|
||||
def __init__(self, site, user, enrollments=None, uuid=None):
|
||||
self.site = site
|
||||
self.user = user
|
||||
|
||||
self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
|
||||
@@ -89,9 +90,9 @@ class ProgramProgressMeter(object):
|
||||
self.course_run_ids.append(enrollment_id)
|
||||
|
||||
if uuid:
|
||||
self.programs = [get_programs(uuid=uuid)]
|
||||
self.programs = [get_programs(self.site, uuid=uuid)]
|
||||
else:
|
||||
self.programs = attach_program_detail_url(get_programs())
|
||||
self.programs = attach_program_detail_url(get_programs(self.site))
|
||||
|
||||
def invert_programs(self):
|
||||
"""Intersect programs and enrollments.
|
||||
|
||||
@@ -26,5 +26,7 @@ class SiteFactory(DjangoModelFactory):
|
||||
model = Site
|
||||
django_get_or_create = ('domain',)
|
||||
|
||||
# TODO These should be generated. Otherwise, code that creates multiple Site
|
||||
# objects will only end up with a single Site since domain has a unique constraint.
|
||||
domain = 'testserver.fake'
|
||||
name = 'testserver.fake'
|
||||
|
||||
@@ -174,7 +174,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
Test that a client (logged in) can get her own username.
|
||||
"""
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
self._verify_get_own_username(15)
|
||||
self._verify_get_own_username(14)
|
||||
|
||||
def test_get_username_inactive(self):
|
||||
"""
|
||||
@@ -184,7 +184,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
self._verify_get_own_username(15)
|
||||
self._verify_get_own_username(14)
|
||||
|
||||
def test_get_username_not_logged_in(self):
|
||||
"""
|
||||
@@ -193,7 +193,7 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
"""
|
||||
|
||||
# verify that the endpoint is inaccessible when not logged in
|
||||
self._verify_get_own_username(13, expected_status=401)
|
||||
self._verify_get_own_username(12, expected_status=401)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -305,7 +305,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
"""
|
||||
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
|
||||
self.create_mock_profile(self.user)
|
||||
with self.assertNumQueries(19):
|
||||
with self.assertNumQueries(18):
|
||||
response = self.send_get(self.different_client)
|
||||
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
|
||||
|
||||
@@ -320,7 +320,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
"""
|
||||
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
|
||||
self.create_mock_profile(self.user)
|
||||
with self.assertNumQueries(19):
|
||||
with self.assertNumQueries(18):
|
||||
response = self.send_get(self.different_client)
|
||||
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
|
||||
|
||||
@@ -395,12 +395,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
self.assertEqual(False, data["accomplishments_shared"])
|
||||
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
verify_get_own_information(17)
|
||||
verify_get_own_information(16)
|
||||
|
||||
# Now make sure that the user can get the same information, even if not active
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
verify_get_own_information(11)
|
||||
verify_get_own_information(10)
|
||||
|
||||
def test_get_account_empty_string(self):
|
||||
"""
|
||||
@@ -414,7 +414,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
legacy_profile.save()
|
||||
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
with self.assertNumQueries(17):
|
||||
with self.assertNumQueries(16):
|
||||
response = self.send_get(self.client)
|
||||
for empty_field in ("level_of_education", "gender", "country", "bio"):
|
||||
self.assertIsNone(response.data[empty_field])
|
||||
|
||||
@@ -10,15 +10,7 @@ from django.test import RequestFactory
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
from ..user_messages import (
|
||||
register_error_message,
|
||||
register_info_message,
|
||||
register_success_message,
|
||||
register_user_message,
|
||||
register_warning_message,
|
||||
user_messages,
|
||||
UserMessageType,
|
||||
)
|
||||
from ..user_messages import PageLevelMessages, UserMessageType
|
||||
|
||||
TEST_MESSAGE = 'Test message'
|
||||
|
||||
@@ -26,7 +18,7 @@ TEST_MESSAGE = 'Test message'
|
||||
@ddt.ddt
|
||||
class UserMessagesTestCase(TestCase):
|
||||
"""
|
||||
Unit tests for user messages.
|
||||
Unit tests for page level user messages.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(UserMessagesTestCase, self).setUp()
|
||||
@@ -46,8 +38,8 @@ class UserMessagesTestCase(TestCase):
|
||||
"""
|
||||
Verifies that a user message is escaped correctly.
|
||||
"""
|
||||
register_user_message(self.request, UserMessageType.INFO, message)
|
||||
messages = list(user_messages(self.request))
|
||||
PageLevelMessages.register_user_message(self.request, UserMessageType.INFO, message)
|
||||
messages = list(PageLevelMessages.user_messages(self.request))
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEquals(messages[0].message_html, expected_message_html)
|
||||
|
||||
@@ -62,17 +54,17 @@ class UserMessagesTestCase(TestCase):
|
||||
"""
|
||||
Verifies that a user message returns the correct CSS and icon classes.
|
||||
"""
|
||||
register_user_message(self.request, message_type, TEST_MESSAGE)
|
||||
messages = list(user_messages(self.request))
|
||||
PageLevelMessages.register_user_message(self.request, message_type, TEST_MESSAGE)
|
||||
messages = list(PageLevelMessages.user_messages(self.request))
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEquals(messages[0].css_class, expected_css_class)
|
||||
self.assertEquals(messages[0].icon_class, expected_icon_class)
|
||||
|
||||
@ddt.data(
|
||||
(register_error_message, UserMessageType.ERROR),
|
||||
(register_info_message, UserMessageType.INFO),
|
||||
(register_success_message, UserMessageType.SUCCESS),
|
||||
(register_warning_message, UserMessageType.WARNING),
|
||||
(PageLevelMessages.register_error_message, UserMessageType.ERROR),
|
||||
(PageLevelMessages.register_info_message, UserMessageType.INFO),
|
||||
(PageLevelMessages.register_success_message, UserMessageType.SUCCESS),
|
||||
(PageLevelMessages.register_warning_message, UserMessageType.WARNING),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_message_type(self, register_message_function, expected_message_type):
|
||||
@@ -80,6 +72,6 @@ class UserMessagesTestCase(TestCase):
|
||||
Verifies that each user message function returns the correct type.
|
||||
"""
|
||||
register_message_function(self.request, TEST_MESSAGE)
|
||||
messages = list(user_messages(self.request))
|
||||
messages = list(PageLevelMessages.user_messages(self.request))
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertEquals(messages[0].type, expected_message_type)
|
||||
|
||||
@@ -14,12 +14,12 @@ There are two common use cases:
|
||||
used to show a success message to the use.
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from enum import Enum
|
||||
|
||||
from django.contrib import messages
|
||||
from openedx.core.djangolib.markup import Text
|
||||
|
||||
EDX_USER_MESSAGE_TAG = 'edx-user-message'
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.markup import Text, HTML
|
||||
|
||||
|
||||
class UserMessageType(Enum):
|
||||
@@ -49,7 +49,7 @@ ICON_CLASSES = {
|
||||
|
||||
class UserMessage():
|
||||
"""
|
||||
Representation of a message to be shown to a user
|
||||
Representation of a message to be shown to a user.
|
||||
"""
|
||||
def __init__(self, type, message_html):
|
||||
assert isinstance(type, UserMessageType)
|
||||
@@ -67,71 +67,124 @@ class UserMessage():
|
||||
def icon_class(self):
|
||||
"""
|
||||
Returns the CSS icon class representing the message type.
|
||||
Returns:
|
||||
"""
|
||||
return ICON_CLASSES[self.type]
|
||||
|
||||
|
||||
def register_user_message(request, message_type, message, title=None):
|
||||
class UserMessageCollection():
|
||||
"""
|
||||
Register a message to be shown to the user in the next page.
|
||||
A collection of messages to be shown to a user.
|
||||
"""
|
||||
assert isinstance(message_type, UserMessageType)
|
||||
messages.add_message(request, message_type.value, Text(message), extra_tags=EDX_USER_MESSAGE_TAG)
|
||||
|
||||
|
||||
def register_info_message(request, message, **kwargs):
|
||||
"""
|
||||
Registers an information message to be shown to the user.
|
||||
"""
|
||||
register_user_message(request, UserMessageType.INFO, message, **kwargs)
|
||||
|
||||
|
||||
def register_success_message(request, message, **kwargs):
|
||||
"""
|
||||
Registers a success message to be shown to the user.
|
||||
"""
|
||||
register_user_message(request, UserMessageType.SUCCESS, message, **kwargs)
|
||||
|
||||
|
||||
def register_warning_message(request, message, **kwargs):
|
||||
"""
|
||||
Registers a warning message to be shown to the user.
|
||||
"""
|
||||
register_user_message(request, UserMessageType.WARNING, message, **kwargs)
|
||||
|
||||
|
||||
def register_error_message(request, message, **kwargs):
|
||||
"""
|
||||
Registers an error message to be shown to the user.
|
||||
"""
|
||||
register_user_message(request, UserMessageType.ERROR, message, **kwargs)
|
||||
|
||||
|
||||
def user_messages(request):
|
||||
"""
|
||||
Returns any outstanding user messages.
|
||||
|
||||
Note: this function also marks these messages as being complete
|
||||
so they won't be returned in the next request.
|
||||
"""
|
||||
def _get_message_type_for_level(level):
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_namespace(self):
|
||||
"""
|
||||
Returns the user message type associated with a level.
|
||||
"""
|
||||
for __, type in UserMessageType.__members__.items():
|
||||
if type.value is level:
|
||||
return type
|
||||
raise 'Unable to find UserMessageType for level {level}'.format(level=level)
|
||||
Returns the namespace of the message collection.
|
||||
|
||||
def _create_user_message(message):
|
||||
The name is used to namespace the subset of django messages.
|
||||
For example, return 'course_home_messages'.
|
||||
"""
|
||||
Creates a user message from a Django message.
|
||||
"""
|
||||
return UserMessage(
|
||||
type=_get_message_type_for_level(message.level),
|
||||
message_html=unicode(message.message),
|
||||
)
|
||||
raise NotImplementedError('Subclasses must define a namespace for messages.')
|
||||
|
||||
django_messages = messages.get_messages(request)
|
||||
return (_create_user_message(message) for message in django_messages if EDX_USER_MESSAGE_TAG in message.tags)
|
||||
@classmethod
|
||||
def get_message_html(self, body_html, title=None):
|
||||
"""
|
||||
Returns the entire HTML snippet for the message.
|
||||
|
||||
Classes that extend this base class can override the message styling
|
||||
by implementing their own version of this function. Messages that do
|
||||
not use a title can just pass the body_html.
|
||||
"""
|
||||
if title:
|
||||
return Text(_('{header_open}{title}{header_close}{body}')).format(
|
||||
header_open=HTML('<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.
|
||||
|
||||
Arguments:
|
||||
message_type (UserMessageType): the user message type
|
||||
body_html (str): body of the message in html format
|
||||
title (str): optional title for the message as plain text
|
||||
"""
|
||||
assert isinstance(message_type, UserMessageType)
|
||||
message = Text(self.get_message_html(body_html, title))
|
||||
messages.add_message(request, message_type.value, Text(message), extra_tags=self.get_namespace())
|
||||
|
||||
@classmethod
|
||||
def register_info_message(self, request, message, **kwargs):
|
||||
"""
|
||||
Registers an information message to be shown to the user.
|
||||
"""
|
||||
self.register_user_message(request, UserMessageType.INFO, message, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def register_success_message(self, request, message, **kwargs):
|
||||
"""
|
||||
Registers a success message to be shown to the user.
|
||||
"""
|
||||
self.register_user_message(request, UserMessageType.SUCCESS, message, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def register_warning_message(self, request, message, **kwargs):
|
||||
"""
|
||||
Registers a warning message to be shown to the user.
|
||||
"""
|
||||
self.register_user_message(request, UserMessageType.WARNING, message, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def register_error_message(self, request, message, **kwargs):
|
||||
"""
|
||||
Registers an error message to be shown to the user.
|
||||
"""
|
||||
self.register_user_message(request, UserMessageType.ERROR, message, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def user_messages(self, request):
|
||||
"""
|
||||
Returns any outstanding user messages.
|
||||
|
||||
Note: this function also marks these messages as being complete
|
||||
so they won't be returned in the next request.
|
||||
"""
|
||||
def _get_message_type_for_level(level):
|
||||
"""
|
||||
Returns the user message type associated with a level.
|
||||
"""
|
||||
for __, type in UserMessageType.__members__.items():
|
||||
if type.value is level:
|
||||
return type
|
||||
raise 'Unable to find UserMessageType for level {level}'.format(level=level)
|
||||
|
||||
def _create_user_message(message):
|
||||
"""
|
||||
Creates a user message from a Django message.
|
||||
"""
|
||||
return UserMessage(
|
||||
type=_get_message_type_for_level(message.level),
|
||||
message_html=unicode(message.message),
|
||||
)
|
||||
|
||||
django_messages = messages.get_messages(request)
|
||||
return (_create_user_message(message) for message in django_messages if self.get_namespace() in message.tags)
|
||||
|
||||
|
||||
class PageLevelMessages(UserMessageCollection):
|
||||
"""
|
||||
This set of messages appears as top page level messages.
|
||||
"""
|
||||
NAMESPACE = 'page_level_messages'
|
||||
|
||||
@classmethod
|
||||
def get_namespace(self):
|
||||
"""
|
||||
Returns the namespace of the message collection.
|
||||
"""
|
||||
return self.NAMESPACE
|
||||
|
||||
@@ -13,6 +13,13 @@ class CourseBookmarksTool(CourseTool):
|
||||
"""
|
||||
The course bookmarks tool.
|
||||
"""
|
||||
@classmethod
|
||||
def analytics_id(cls):
|
||||
"""
|
||||
Returns an id to uniquely identify this tool in analytics events.
|
||||
"""
|
||||
return 'edx.bookmarks'
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls, request, course_key):
|
||||
"""
|
||||
|
||||
@@ -3,7 +3,8 @@ Unified course experience settings and helper methods.
|
||||
"""
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlag, WaffleFlagNamespace
|
||||
from openedx.core.djangoapps.util.user_messages import UserMessageCollection
|
||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace
|
||||
|
||||
|
||||
# Namespace for course experience waffle flags.
|
||||
@@ -58,3 +59,17 @@ def course_home_url_name(course_key):
|
||||
return 'openedx.course_experience.course_home'
|
||||
else:
|
||||
return 'info'
|
||||
|
||||
|
||||
class CourseHomeMessages(UserMessageCollection):
|
||||
"""
|
||||
This set of messages appear above the outline on the course home page.
|
||||
"""
|
||||
NAMESPACE = 'course_home_level_messages'
|
||||
|
||||
@classmethod
|
||||
def get_namespace(self):
|
||||
"""
|
||||
Returns the namespace of the message collection.
|
||||
"""
|
||||
return self.NAMESPACE
|
||||
|
||||
@@ -16,6 +16,14 @@ class CourseTool(object):
|
||||
not a requirement, and plugin implementations outside of this repo should
|
||||
simply follow the contract defined below.
|
||||
"""
|
||||
@classmethod
|
||||
def analytics_id(cls):
|
||||
"""
|
||||
Returns an id to uniquely identify this tool in analytics events.
|
||||
|
||||
For example, 'edx.bookmarks'. New tools may warrant doc updates for the new id.
|
||||
"""
|
||||
raise NotImplementedError("Must specify an id to enable course tool eventing.")
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls, request, course_key):
|
||||
|
||||
@@ -19,6 +19,13 @@ class CourseUpdatesTool(CourseTool):
|
||||
"""
|
||||
The course updates tool.
|
||||
"""
|
||||
@classmethod
|
||||
def analytics_id(cls):
|
||||
"""
|
||||
Returns an analytics id for this tool, used for eventing.
|
||||
"""
|
||||
return 'edx.updates'
|
||||
|
||||
@classmethod
|
||||
def title(cls):
|
||||
"""
|
||||
@@ -57,6 +64,13 @@ class CourseReviewsTool(CourseTool):
|
||||
"""
|
||||
The course reviews tool.
|
||||
"""
|
||||
@classmethod
|
||||
def analytics_id(cls):
|
||||
"""
|
||||
Returns an id to uniquely identify this tool in analytics events.
|
||||
"""
|
||||
return 'edx.reviews'
|
||||
|
||||
@classmethod
|
||||
def title(cls):
|
||||
"""
|
||||
|
||||
@@ -67,19 +67,19 @@
|
||||
<h3 class="hd-6">Course Tools</h3>
|
||||
<ul class="list-unstyled">
|
||||
<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>
|
||||
Bookmarks
|
||||
</a>
|
||||
</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>
|
||||
Reviews
|
||||
</a>
|
||||
</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>
|
||||
Updates
|
||||
</a>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
@@ -4,13 +4,12 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export
|
||||
constructor(options) {
|
||||
// Logging for course tool click events
|
||||
const $courseToolLink = $(options.courseToolLink);
|
||||
$courseToolLink.on('click', () => {
|
||||
const courseToolName = document.querySelector('.course-tool-link').text.trim().toLowerCase();
|
||||
$courseToolLink.on('click', (event) => {
|
||||
const courseToolName = event.srcElement.dataset['analytics-id']; // eslint-disable-line dot-notation
|
||||
Logger.log(
|
||||
'edx.course.tool.accessed',
|
||||
{
|
||||
tool_name: courseToolName,
|
||||
page: 'course_home',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,15 +15,19 @@ describe('Course Home factory', () => {
|
||||
});
|
||||
|
||||
it('sends an event when an course tool is clicked', () => {
|
||||
document.querySelector('.course-tool-link').dispatchEvent(new Event('click'));
|
||||
const courseToolName = document.querySelector('.course-tool-link').text.trim().toLowerCase();
|
||||
expect(Logger.log).toHaveBeenCalledWith(
|
||||
'edx.course.tool.accessed',
|
||||
{
|
||||
tool_name: courseToolName,
|
||||
page: 'course_home',
|
||||
},
|
||||
);
|
||||
const courseToolNames = document.querySelectorAll('.course-tool-link');
|
||||
for (let i = 0; i < courseToolNames.length; i += 1) {
|
||||
const courseToolName = courseToolNames[i].dataset['analytics-id']; // eslint-disable-line dot-notation
|
||||
const event = new CustomEvent('click');
|
||||
event.srcElement = { dataset: { 'analytics-id': courseToolName } };
|
||||
courseToolNames[i].dispatchEvent(event);
|
||||
expect(Logger.log).toHaveBeenCalledWith(
|
||||
'edx.course.tool.accessed',
|
||||
{
|
||||
tool_name: courseToolName,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,10 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
|
||||
<div class="page-content">
|
||||
<div class="layout layout-1t2t">
|
||||
<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):
|
||||
<div class="section section-dates">
|
||||
${HTML(welcome_message_fragment.body_html())}
|
||||
@@ -74,7 +78,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
|
||||
<ul class="list-unstyled">
|
||||
% for course_tool in course_tools:
|
||||
<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>
|
||||
${course_tool.title()}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
## mako
|
||||
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
|
||||
<%!
|
||||
from django.utils.translation import get_language_bidi
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
from openedx.features.course_experience import CourseHomeMessages
|
||||
%>
|
||||
|
||||
<%
|
||||
is_rtl = get_language_bidi()
|
||||
%>
|
||||
|
||||
% if course_home_messages:
|
||||
% for message in course_home_messages:
|
||||
<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 @@
|
||||
"""
|
||||
Tests for the course home page.
|
||||
"""
|
||||
import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import ddt
|
||||
import mock
|
||||
import pytz
|
||||
from pytz import UTC
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
from courseware.tests.factories import StaffFactory
|
||||
@@ -31,6 +31,10 @@ TEST_CHAPTER_NAME = 'Test Chapter'
|
||||
TEST_WELCOME_MESSAGE = '<h2>Welcome!</h2>'
|
||||
TEST_UPDATE_MESSAGE = '<h2>Test Update!</h2>'
|
||||
TEST_COURSE_UPDATES_TOOL = '/course/updates">'
|
||||
TEST_COURSE_HOME_MESSAGE = 'course-message'
|
||||
TEST_COURSE_HOME_MESSAGE_ANONYMOUS = '/login'
|
||||
TEST_COURSE_HOME_MESSAGE_UNENROLLED = 'Enroll now'
|
||||
TEST_COURSE_HOME_MESSAGE_PRE_START = 'Course starts in'
|
||||
|
||||
QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES
|
||||
|
||||
@@ -73,7 +77,12 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
|
||||
# pylint: disable=super-method-not-called
|
||||
with super(CourseHomePageTestCase, cls).setUpClassAndTestData():
|
||||
with cls.store.default_store(ModuleStoreEnum.Type.split):
|
||||
cls.course = CourseFactory.create(org='edX', number='test', display_name='Test Course')
|
||||
cls.course = CourseFactory.create(
|
||||
org='edX',
|
||||
number='test',
|
||||
display_name='Test Course',
|
||||
start=datetime.now(UTC) - timedelta(days=30),
|
||||
)
|
||||
with cls.store.bulk_operations(cls.course.id):
|
||||
chapter = ItemFactory.create(
|
||||
category='chapter',
|
||||
@@ -92,6 +101,15 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
|
||||
cls.user = UserFactory(password=TEST_PASSWORD)
|
||||
CourseEnrollment.enroll(cls.user, cls.course.id)
|
||||
|
||||
def create_future_course(self, specific_date=None):
|
||||
"""
|
||||
Creates and returns a course in the future.
|
||||
"""
|
||||
return CourseFactory.create(
|
||||
display_name='Test Future Course',
|
||||
start=specific_date if specific_date else datetime.now(UTC) + timedelta(days=30),
|
||||
)
|
||||
|
||||
|
||||
class TestCourseHomePage(CourseHomePageTestCase):
|
||||
def setUp(self):
|
||||
@@ -142,7 +160,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
|
||||
course_home_url(self.course)
|
||||
|
||||
# Fetch the view and verify the query counts
|
||||
with self.assertNumQueries(38, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(37, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_home_url(self.course)
|
||||
self.client.get(url)
|
||||
@@ -152,18 +170,15 @@ class TestCourseHomePage(CourseHomePageTestCase):
|
||||
"""
|
||||
Verify that the course home page handles start dates correctly.
|
||||
"""
|
||||
now = datetime.datetime.now(pytz.UTC)
|
||||
tomorrow = now + datetime.timedelta(days=1)
|
||||
self.course.start = tomorrow
|
||||
|
||||
# The course home page should 404 for a course starting in the future
|
||||
url = course_home_url(self.course)
|
||||
future_course = self.create_future_course(datetime(2030, 1, 1, tzinfo=UTC))
|
||||
url = course_home_url(future_course)
|
||||
response = self.client.get(url)
|
||||
self.assertRedirects(response, '/dashboard?notlive=Jan+01%2C+2030')
|
||||
|
||||
# With the Waffle flag enabled, the course should be visible
|
||||
with override_flag(COURSE_PRE_START_ACCESS_FLAG.namespaced_flag_name, True):
|
||||
url = course_home_url(self.course)
|
||||
url = course_home_url(future_course)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -272,11 +287,12 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
|
||||
Ensure that a user accessing a non-live course sees a redirect to
|
||||
the student dashboard, not a 404.
|
||||
"""
|
||||
self.user = self.create_user_for_course(self.course, CourseUserType.ENROLLED)
|
||||
future_course = self.create_future_course()
|
||||
self.user = self.create_user_for_course(future_course, CourseUserType.ENROLLED)
|
||||
|
||||
url = course_home_url(self.course)
|
||||
url = course_home_url(future_course)
|
||||
response = self.client.get(url)
|
||||
start_date = strftime_localized(self.course.start, 'SHORT_DATE')
|
||||
start_date = strftime_localized(future_course.start, 'SHORT_DATE')
|
||||
expected_params = QueryDict(mutable=True)
|
||||
expected_params['notlive'] = start_date
|
||||
expected_url = '{url}?{params}'.format(
|
||||
@@ -292,12 +308,13 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
|
||||
Ensure that a user accessing a non-live course sees a redirect to
|
||||
the student dashboard, not a 404, even if the localized date is unicode
|
||||
"""
|
||||
self.user = self.create_user_for_course(self.course, CourseUserType.ENROLLED)
|
||||
future_course = self.create_future_course()
|
||||
self.user = self.create_user_for_course(future_course, CourseUserType.ENROLLED)
|
||||
|
||||
fake_unicode_start_time = u"üñîçø∂é_ßtå®t_tîµé"
|
||||
mock_strftime_localized.return_value = fake_unicode_start_time
|
||||
|
||||
url = course_home_url(self.course)
|
||||
url = course_home_url(future_course)
|
||||
response = self.client.get(url)
|
||||
expected_params = QueryDict(mutable=True)
|
||||
expected_params['notlive'] = fake_unicode_start_time
|
||||
@@ -316,3 +333,44 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
|
||||
url = course_home_url_from_string('not/a/course')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True)
|
||||
@override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True)
|
||||
def test_course_messaging(self):
|
||||
"""
|
||||
Ensure that the following four use cases work as expected
|
||||
|
||||
1) Anonymous users are shown a course message linking them to the login page
|
||||
2) Unenrolled users are shown a course message allowing them to enroll
|
||||
3) Enrolled users who show up on the course page after the course has begun
|
||||
are not shown a course message.
|
||||
4) Enrolled users who show up on the course page before the course begins
|
||||
are shown a message explaining when the course starts as well as a call to
|
||||
action button that allows them to add a calendar event.
|
||||
"""
|
||||
# Verify that anonymous users are shown a login link in the course message
|
||||
url = course_home_url(self.course)
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE_ANONYMOUS)
|
||||
|
||||
# Verify that unenrolled users are shown an enroll call to action message
|
||||
self.user = self.create_user_for_course(self.course, CourseUserType.UNENROLLED)
|
||||
url = course_home_url(self.course)
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED)
|
||||
|
||||
# Verify that enrolled users are not shown a message when enrolled and course has begun
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
url = course_home_url(self.course)
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE)
|
||||
|
||||
# Verify that enrolled users are shown 'days until start' message before start date
|
||||
future_course = self.create_future_course()
|
||||
CourseEnrollment.enroll(self.user, future_course.id)
|
||||
url = course_home_url(future_course)
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
|
||||
self.assertContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START)
|
||||
|
||||
@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
|
||||
course_updates_url(self.course)
|
||||
|
||||
# Fetch the view and verify that the query counts haven't changed
|
||||
with self.assertNumQueries(31, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with self.assertNumQueries(30, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
|
||||
with check_mongo_calls(4):
|
||||
url = course_updates_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -26,6 +26,7 @@ from web_fragments.fragment import Fragment
|
||||
|
||||
from ..utils import get_course_outline_block_tree
|
||||
from .course_dates import CourseDatesFragmentView
|
||||
from .course_home_messages import CourseHomeMessageFragmentView
|
||||
from .course_outline import CourseOutlineFragmentView
|
||||
from .course_sock import CourseSockFragmentView
|
||||
from .welcome_message import WelcomeMessageFragmentView
|
||||
@@ -113,9 +114,12 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
|
||||
# Render the full content to enrolled users, as well as to course and global staff.
|
||||
# Unenrolled users who are not course or global staff are given only a subset.
|
||||
is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key)
|
||||
is_staff = has_access(request.user, 'staff', course_key)
|
||||
if is_enrolled or is_staff:
|
||||
user_access = {
|
||||
'is_anonymous': request.user.is_anonymous(),
|
||||
'is_enrolled': CourseEnrollment.is_enrolled(request.user, course_key),
|
||||
'is_staff': has_access(request.user, 'staff', course_key),
|
||||
}
|
||||
if user_access['is_enrolled'] or user_access['is_staff']:
|
||||
outline_fragment = CourseOutlineFragmentView().render_to_fragment(request, course_id=course_id, **kwargs)
|
||||
welcome_message_fragment = WelcomeMessageFragmentView().render_to_fragment(
|
||||
request, course_id=course_id, **kwargs
|
||||
@@ -141,6 +145,11 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
# Get the course tools enabled for this user and course
|
||||
course_tools = CourseToolsPluginManager.get_enabled_course_tools(request, course_key)
|
||||
|
||||
# Grab the course home messages fragment to render any relevant django messages
|
||||
course_home_message_fragment = CourseHomeMessageFragmentView().render_to_fragment(
|
||||
request, course_id=course_id, user_access=user_access, **kwargs
|
||||
)
|
||||
|
||||
# Render the course home fragment
|
||||
context = {
|
||||
'request': request,
|
||||
@@ -149,6 +158,7 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
'course_key': course_key,
|
||||
'outline_fragment': outline_fragment,
|
||||
'handouts_html': handouts_html,
|
||||
'course_home_message_fragment': course_home_message_fragment,
|
||||
'has_visited_course': has_visited_course,
|
||||
'resume_course_url': resume_course_url,
|
||||
'course_tools': course_tools,
|
||||
|
||||
126
openedx/features/course_experience/views/course_home_messages.py
Normal file
126
openedx/features/course_experience/views/course_home_messages.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
View logic for handling course messages.
|
||||
"""
|
||||
|
||||
from babel.dates import format_date, format_timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from courseware.courses import get_course_with_access
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.http import urlquote_plus
|
||||
from django.utils.timezone import UTC
|
||||
from django.utils.translation import get_language, to_locale
|
||||
from django.utils.translation import ugettext as _
|
||||
from openedx.core.djangolib.markup import Text, HTML
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from web_fragments.fragment import Fragment
|
||||
|
||||
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
from openedx.features.course_experience import CourseHomeMessages
|
||||
|
||||
|
||||
class CourseHomeMessageFragmentView(EdxFragmentView):
|
||||
"""
|
||||
A fragment that displays a course message with an alert and call
|
||||
to action for three types of users:
|
||||
|
||||
1) Not logged in users are given a link to sign in or register.
|
||||
2) Unenrolled users are given a link to enroll.
|
||||
3) Enrolled users who get to the page before the course start date
|
||||
are given the option to add the start date to their calendar.
|
||||
|
||||
This fragment requires a user_access map as follows:
|
||||
|
||||
user_access = {
|
||||
'is_anonymous': True if the user is logged in, False otherwise
|
||||
'is_enrolled': True if the user is enrolled in the course, False otherwise
|
||||
'is_staff': True if the user is a staff member of the course, False otherwise
|
||||
}
|
||||
"""
|
||||
def render_to_fragment(self, request, course_id, user_access, **kwargs):
|
||||
"""
|
||||
Renders a course message fragment for the specified course.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = get_course_with_access(request.user, 'load', course_key)
|
||||
|
||||
# Get time until the start date, if already started, or no start date, value will be zero or negative
|
||||
now = datetime.now(UTC())
|
||||
already_started = course.start and now > course.start
|
||||
days_until_start_string = "started" if already_started else format_timedelta(course.start - now, locale=to_locale(get_language()))
|
||||
course_start_data = {
|
||||
'course_start_date': format_date(course.start, locale=to_locale(get_language())),
|
||||
'already_started': already_started,
|
||||
'days_until_start_string': days_until_start_string
|
||||
}
|
||||
|
||||
# Register the course home messages to be loaded on the page
|
||||
self.register_course_home_messages(request, course, user_access, course_start_data)
|
||||
|
||||
# Grab the relevant messages
|
||||
course_home_messages = list(CourseHomeMessages.user_messages(request))
|
||||
|
||||
# Return None if user is enrolled and course has begun
|
||||
if user_access['is_enrolled'] and already_started:
|
||||
return None
|
||||
|
||||
# Grab the logo
|
||||
image_src = "course_experience/images/home_message_author.png"
|
||||
|
||||
context = {
|
||||
'course_home_messages': course_home_messages,
|
||||
'image_src': image_src,
|
||||
}
|
||||
|
||||
html = render_to_string('course_experience/course-messages-fragment.html', context)
|
||||
return Fragment(html)
|
||||
|
||||
@staticmethod
|
||||
def register_course_home_messages(request, course, user_access, course_start_data):
|
||||
"""
|
||||
Register messages to be shown in the course home content page.
|
||||
"""
|
||||
if user_access['is_anonymous']:
|
||||
CourseHomeMessages.register_info_message(
|
||||
request,
|
||||
Text(_(
|
||||
" {sign_in_link} or {register_link} and then enroll in this course."
|
||||
)).format(
|
||||
sign_in_link=HTML("<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
|
||||
try:
|
||||
from enterprise import utils as enterprise_utils
|
||||
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomer
|
||||
from enterprise.tpa_pipeline import get_enterprise_customer_for_request
|
||||
from enterprise.utils import consent_necessary_for_course
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -240,9 +239,9 @@ def enterprise_customer_for_request(request, tpa_hint=None):
|
||||
if not enterprise_enabled():
|
||||
return None
|
||||
|
||||
ec = get_enterprise_customer_for_request(request)
|
||||
ec = None
|
||||
|
||||
if not ec and tpa_hint:
|
||||
if tpa_hint:
|
||||
try:
|
||||
ec = EnterpriseCustomer.objects.get(enterprise_customer_identity_provider__provider_id=tpa_hint)
|
||||
except EnterpriseCustomer.DoesNotExist:
|
||||
@@ -308,24 +307,6 @@ def get_enterprise_consent_url(request, course_id, user=None, return_to=None):
|
||||
return full_url
|
||||
|
||||
|
||||
def insert_enterprise_pipeline_elements(pipeline):
|
||||
"""
|
||||
If the enterprise app is enabled, insert additional elements into the
|
||||
pipeline so that data sharing consent views are used.
|
||||
"""
|
||||
if not enterprise_enabled():
|
||||
return
|
||||
|
||||
additional_elements = (
|
||||
'enterprise.tpa_pipeline.handle_enterprise_logistration',
|
||||
)
|
||||
# Find the item we need to insert the data sharing consent elements before
|
||||
insert_point = pipeline.index('social_core.pipeline.social_auth.load_extra_data')
|
||||
|
||||
for index, element in enumerate(additional_elements):
|
||||
pipeline.insert(insert_point + index, element)
|
||||
|
||||
|
||||
def get_cache_key(**kwargs):
|
||||
"""
|
||||
Get MD5 encoded cache key for given arguments.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user