Merge branch 'master' into release-mergeback-to-master

This commit is contained in:
Feanil Patel
2017-07-25 14:00:33 -04:00
committed by GitHub
95 changed files with 2911 additions and 425 deletions

View File

@@ -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

View File

@@ -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'):

View File

@@ -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)

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CELERY_BROKER_PASSWORD": "celery",

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_SERVER_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CELERY_BROKER_PASSWORD": "celery",

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_SERVER_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",
"BULK_EMAIL_DEFAULT_FROM_EMAIL": "no-reply@example.com",

View File

@@ -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'
]

View File

@@ -10,7 +10,7 @@
// +Base - Utilities
// ====================
@import 'partials/variables';
@import 'cms/base/variables';
@import 'mixins';
@import 'mixins-inherited';

View File

@@ -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';

View 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;
}

View 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;
}
}
}

View 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;
}
}
}
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}
}
}
}

View 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;

View 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';

View File

@@ -0,0 +1,5 @@
// Default bootstrap theming
$body-bg: #f5f5f5 !default;
@import 'edx-bootstrap/sass/open-edx/theme';

View File

@@ -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" />

View File

@@ -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
-->

View 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">&gt; </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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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'],
))

View File

@@ -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)

View File

@@ -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)]))

View File

@@ -405,7 +405,7 @@ class ViewsQueryCountTestCase(
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 4, 31),
(ModuleStoreEnum.Type.split, 3, 12, 31),
(ModuleStoreEnum.Type.split, 3, 13, 31),
)
@ddt.unpack
@count_queries
@@ -414,7 +414,7 @@ class ViewsQueryCountTestCase(
@ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 3, 27),
(ModuleStoreEnum.Type.split, 3, 9, 27),
(ModuleStoreEnum.Type.split, 3, 10, 27),
)
@ddt.unpack
@count_queries

View File

@@ -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:

View File

@@ -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)

View File

@@ -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())

View File

@@ -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,

View File

@@ -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))

View File

@@ -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

View File

@@ -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")

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CC_PROCESSOR_NAME": "CyberSource2",

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_SERVER_URL": "",
"ANALYTICS_DASHBOARD_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_API_KEY": "",
"AWS_ACCESS_KEY_ID": "",
"AWS_SECRET_ACCESS_KEY": "",
"CC_PROCESSOR_NAME": "CyberSource2",

View File

@@ -1,5 +1,4 @@
{
"ANALYTICS_SERVER_URL": "",
"ANALYTICS_DASHBOARD_URL": "",
"BOOK_URL": "",
"BUGS_EMAIL": "bugs@example.com",

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -14,17 +14,17 @@
text-align: left !important;
}
.v2.register-choice-donate .list-actions {
.v2.register-choice-continue .list-actions {
margin-bottom: 0 !important;
}
.v2.register-choice-donate .action-select {
.v2.register-choice-continue .action-select {
display: inline-block !important;
list-style-type: none !important;
width: 100% !important;
}
.v2.register-choice-donate .donation-link {
.v2.register-choice-continue .continue-link {
display: inline-block !important;
padding: 10px 15px !important;
border-radius: 3px !important;
@@ -38,13 +38,7 @@
font-weight: 500 !important;
}
@media (min-width: 375px) {
.donation-link {
font-size: 16px;
}
}
.v2.register-choice-v2-audit {
.v2.register-choice-v2-donate {
height: 300px;
background: none !important;
border-top-color: grey !important;
@@ -52,16 +46,16 @@
}
@media screen and (min-width: 375px) {
.v2.register-choice-v2-audit {
.v2.register-choice-v2-donate {
height: 250px;
}
}
.v2.register-choice-v2-audit .list-actions {
.v2.register-choice-v2-donate .list-actions {
margin-bottom: 0 !important;
}
.v2.register-choice-v2-audit .list-actions input {
.v2.register-choice-v2-donate .list-actions a {
background: transparent !important;
color: #0075b4 !important;
box-shadow: none !important;
@@ -70,13 +64,13 @@
white-space: normal;
}
.v2.register-choice-v2-audit .wrapper-copy-inline {
.v2.register-choice-v2-donate .wrapper-copy-inline {
height: 70px !important;
width: 100% !important;
display: flex !important;
}
.v2.register-choice-v2-audit .wrapper-copy {
.v2.register-choice-v2-donate .wrapper-copy {
width: 70% !important;
height: auto !important;
}
@@ -90,17 +84,17 @@
margin-left: 5px;
}
.v2 .donation-link {
.v2 .continue-link {
font-weight: bold !important;
}
.v2.register-choice-certificate,
.v2.register-choice-donate,
.v2.register-choice-continue,
.v2.register-choice-view {
width: 100%;
}
.v2.register-choice-donate {
.v2.register-choice-continue {
border-color: #D7548E !important;
}
@@ -108,15 +102,15 @@
max-height: 115px;
}
.v2.register-choice-v2-audit .wrapper-copy-inline {
.v2.register-choice-v2-donate .wrapper-copy-inline {
display: block !important;
}
.v2.register-choice-v2-audit .copy-inline {
.v2.register-choice-v2-donate .copy-inline {
width: 100% !important;
}
.v2.register-choice-v2-audit .list-actions {
.v2.register-choice-v2-donate .list-actions {
width: 100% !important;
margin-top: 20px !important;
text-align: center !important;
@@ -126,7 +120,7 @@
width: 100% !important;
}
.v2 input{
.v2 input, .v2 a {
font-size: 15px !important;
}
@@ -164,7 +158,7 @@
}
}
.v2 .donation-link, .v2 input, .v2 button {
.v2 .continue-link, .v2 input, .v2 button, .v2 a {
width: 100%;
}
@@ -189,21 +183,22 @@
@media (min-width: 768px) {
.v2.register-choice-certificate,
.v2.register-choice-donate {
.v2.register-choice-continue,
.v2.deco-divider {
width: 46.5% !important;
display: inline-block;
min-height: 270px;
}
.v2.register-choice-v2-audit .wrapper-copy-inline {
.v2.register-choice-v2-donate .wrapper-copy-inline {
display: flex !important;
}
.v2.register-choice-v2-audit .copy-inline {
.v2.register-choice-v2-donate .copy-inline {
width: 40% !important;
}
.v2.register-choice-v2-audit .list-actions {
.v2.register-choice-v2-donate .list-actions {
margin-top: 0 !important;
text-align: right !important;
}
@@ -212,16 +207,16 @@
width: 100% !important;
}
.v2 input {
.v2 input, .v2 a {
font-size: 15px !important;
}
.v2 .donation-link, .v2.register-choice-certificate button {
.v2 .continue-link, .v2.register-choice-certificate button, .v2.register-choice-certificate input {
margin-top: 20px;
width: initial;
}
.v2.register-choice-v2-audit input {
.v2.register-choice-v2-donate a {
width: 100% !important;
}
@@ -237,7 +232,7 @@
margin: 0 2% 20px 0;
}
.v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
.v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
width: 60%;
}
@@ -249,7 +244,7 @@
padding: 15px !important;
}
.v2.register-choice-donate .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
.v2.register-choice-continue .wrapper-copy-inline .wrapper-copy, .v2.register-choice-certificate .wrapper-copy-inline .wrapper-copy {
width: 60%;
}
@@ -271,14 +266,14 @@
width: 100% !important;
}
.v2 .donation-link:hover,
.v2 .donation-link:focus {
.v2 .continue-link:hover,
.v2 .continue-link:focus {
background-color: #D7548E !important;
color: white !important;
text-decoration: none;
}
.v2 .donation-link:hover {
.v2 .continue-link:hover {
cursor: pointer;
}
@@ -366,13 +361,14 @@
@media (min-width: 835px) {
.v2.register-choice-certificate,
.v2.register-choice-donate {
.v2.register-choice-continue,
.v2.deco-divider {
min-height: 250px;
}
}
@media (min-width: 1024px) {
.v2 .donation-link {
.v2 .continue-link {
width: 55%;
}
.v2.deco-divider .copy {
@@ -380,15 +376,18 @@
}
}
@media (min-width: 1064px) {
@media (min-width: 1096px) {
.v2.register-choice-certificate,
.v2.register-choice-donate {
.v2.register-choice-continue,
.v2.deco-divider {
min-height: 260px;
}
.v2 .img-certificate, .v2 .img-donate {
margin-top: 10px;
display: initial;
}
.v2 .donation-link, .v2.register-choice-certificate button {
.v2 .continue-link, .v2.register-choice-certificate button,
.v2.register-choice-certificate input {
margin-top: -22px !important;
}
}

View File

@@ -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;

View File

@@ -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
// ----------------------------

View File

@@ -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:

View File

@@ -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" />

View File

@@ -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)

View File

@@ -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()}

View File

@@ -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))

View File

@@ -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):

View File

@@ -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

View File

@@ -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,

View File

@@ -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'

View File

@@ -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)

View File

@@ -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.

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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):

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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',
},
);
});

View File

@@ -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,
},
);
}
});
});
});

View File

@@ -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>

View File

@@ -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

View File

@@ -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):
@@ -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)

View File

@@ -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,

View 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']
)
)

View File

@@ -9,7 +9,7 @@
"backbone.paginator": "~2.0.3",
"coffee-loader": "^0.7.3",
"coffee-script": "1.6.1",
"edx-bootstrap": "~0.1.6",
"edx-bootstrap": "~0.1.7",
"edx-pattern-library": "0.18.1",
"edx-ui-toolkit": "1.5.2",
"exports-loader": "^0.6.4",

View File

@@ -56,6 +56,7 @@ edx-organizations==0.4.5
edx-rest-api-client==1.7.1
edx-search==1.1.0
edx-submissions==2.0.5
event-tracking==0.2.4
facebook-sdk==0.4.0
feedparser==5.1.3
firebase-token-generator==1.3.2

View File

@@ -78,7 +78,6 @@ git+https://github.com/edx/django-rest-framework-oauth.git@0a43e8525f1e3048efe4b
# Our libraries:
-e git+https://github.com/edx/codejail.git@a320d43ce6b9c93b17636b2491f724d9e433be47#egg=codejail==0.0
-e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1
-e git+https://github.com/edx/django-splash.git@v0.2#egg=django-splash==0.2
-e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock
git+https://github.com/edx/edx-ora2.git@1.4.8#egg=ora2==1.4.8

View File

@@ -0,0 +1,5 @@
// Override theming for edx.org bootstrap
$body-bg: #f5f5f5 !default;
@import 'edx-bootstrap/sass/edx/theme';

View File

@@ -0,0 +1,5 @@
// Override theming for edx.org bootstrap
$body-bg: #f5f5f5 !default;
@import 'edx-bootstrap/sass/edx/theme';

View File

@@ -47,6 +47,11 @@ from openedx.core.djangolib.markup import HTML, Text
window.location.href = '${ecommerce_payment_page | n, js_escaped_string}?sku=' +
encodeURIComponent('${sku | n, js_escaped_string}');
});
$('.v2 button[name=verified_mode]').click(function(e){
e.preventDefault();
window.location.href = 'https://ecommerce.edx.org/coupons/redeem/?code=EDXTSV35&sku=' +
encodeURIComponent('${sku | n, js_escaped_string}');
});
% endif
});
</script>
@@ -205,7 +210,7 @@ from openedx.core.djangolib.markup import HTML, Text
</span>
<!-- This div was added as part of the LEARNER-1726 experiment. The v2 class should be removed if the experiment is implemented-->
<div class="register-choice register-choice-donate v2 hidden">
<div class="register-choice register-choice-continue v2 hidden">
<h4 class="title">
I Don't Want to Upgrade or Donate Today
</h4>
@@ -218,14 +223,14 @@ from openedx.core.djangolib.markup import HTML, Text
<div class="copy-inline">
<ul class="list-actions">
<li class="action action-select">
<a class="donation-link" href="/dashboard">Continue to Course</a>
<a class="continue-link" href="/dashboard">Continue to Course</a>
</li>
</ul>
</div>
</div>
<!-- This div was added as part of the LEARNER-1726 experiment. The v2 class should be removed if the experiment is implemented-->
<div class="register-choice register-choice-v2-audit register-choice-view v2 hidden">
<div class="register-choice register-choice-v2-donate register-choice-view v2 hidden">
<h4 class="title">Donate to Support our Non-Profit Mission</h4>
<div class="wrapper-copy-inline">
<div class="wrapper-copy">
@@ -233,7 +238,7 @@ from openedx.core.djangolib.markup import HTML, Text
</div>
<div class="copy-inline">
<ul class="list-actions">
<input type="submit" name="audit_mode" action="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=AG9VK2LC29L5Y" value="Donate and Continue to Course">
<a class="donation-link" href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=AG9VK2LC29L5Y">Donate and Continue to Course</a>
</ul>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,13 @@
// Sample red theme to demonstrate SASS overrides
// Theme colors
//
// Note: define colors needed by your theme first
$red: #d9534f !default;
$brand-primary: $red;
// Theme fonts
$font-family-sans-serif: cursive;
// Initialize the Open edX bootstrap theme
@import 'edx-bootstrap/sass/open-edx/theme';

View File

@@ -0,0 +1,14 @@
// Color overrides
$white: rgb(255,255,255);
$red: #d9534f !default;
$footer-bg: $white;
$header-bg: $white;
$header-border-color: $red;
$base-font-color: $red;
$link-color: $red;
$lms-active-color: $red;
$lms-label-color: $red;
@import 'cms/static/sass/partials/cms/base/variables';