From 15de4120acc713ce3e319b4b75f58828e1069b16 Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 11 Apr 2013 23:20:49 -0400 Subject: [PATCH 001/556] blankslate edited, header bar now with home button --- lms/static/sass/_discussion.scss | 89 +++++++++++++++---- .../discussion/_filter_dropdown.html | 8 +- .../discussion/_thread_list_template.html | 4 + lms/templates/discussion/index.html | 12 ++- 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss index 2f044ca5a3..8b7e30179d 100644 --- a/lms/static/sass/_discussion.scss +++ b/lms/static/sass/_discussion.scss @@ -199,7 +199,7 @@ body.discussion { z-index: 9999; width: 100%; @include box-sizing(border-box); - background: #737373; + background: #797979; border: 1px solid #333; box-shadow: 0 2px 50px rgba(0, 0, 0, .4); } @@ -710,7 +710,7 @@ body.discussion { border-radius: 3px 0 0 0; - .browse, + .home, .browse, .search { position: relative; float: left; @@ -723,9 +723,29 @@ body.discussion { &:hover { background-color: #e9e9e9; } + } - &.is-open { - width: 80%; + .home { + border-radius: 3px 0 0 0; + box-shadow: -1px 0 0 #aaa inset; + cursor: pointer; + + .home-icon { + display: block; + position: absolute; + top: 30%; + left: 30%; + z-index: 100; + width: 25px; + height: 25px; + //margin-left: -17px; + background: url(../images/home-discussion-icon.png) no-repeat; + opacity: 1; + @include transition(none); + } + + .home-btn { + //nothing here yet } } @@ -734,6 +754,7 @@ body.discussion { box-shadow: -1px 0 0 #aaa inset; &.is-open { + width:60%; .browse-topic-drop-btn span { opacity: 1; } @@ -774,6 +795,11 @@ body.discussion { &.is-open { cursor: auto; + width: 60%; + + .home { + width:0%; + } .post-search { padding: 0 10px; @@ -801,7 +827,7 @@ body.discussion { z-index: 50; width: 100%; height: 100%; - border-radius: 3px 0 0 0; + border-radius: 0 0 0 0; border: 1px solid transparent; text-align: center; overflow: hidden; @@ -820,6 +846,9 @@ body.discussion { opacity: 0; @include transition(opacity .2s); } + .drop-arrow { + font-size:16px; + } } .browse-topic-drop-icon { @@ -843,7 +872,7 @@ body.discussion { left: -1px; z-index: 9999; width: 100%; - background: #737373; + background: #797979; border: 1px solid #4b4b4b; border-left: none; border-radius: 0 0 3px 3px; @@ -852,8 +881,16 @@ body.discussion { .browse-topic-drop-menu { max-height: 400px; overflow-y: scroll; + + .drop-menu-meta-category span, + .drop-menu-parent-category span { + margin: 10px 0; + font-size: 14px; + font-weight: 700; + } } + ul { position: inline; } @@ -866,7 +903,7 @@ body.discussion { display: block; padding: 0 20px; border-top: 1px solid #5f5f5f; - font-size: 14px; + font-size: 12px; font-weight: 700; line-height: 22px; color: #fff; @@ -885,7 +922,7 @@ body.discussion { .board-name { float: left; width: 80%; - margin: 13px 0; + margin: 5px 0; color: #fff; } @@ -903,14 +940,14 @@ body.discussion { li li { a { padding-left: 44px; - background: url(../images/nested-icon.png) no-repeat 22px 14px; + background: url(../images/nested-icon.png) no-repeat 22px 5px; } } li li li { a { padding-left: 68px; - background: url(../images/nested-icon.png) no-repeat 46px 14px; + background: url(../images/nested-icon.png) no-repeat 46px 5px; } } } @@ -981,7 +1018,7 @@ body.discussion { min-height: 27px; border-bottom: 1px solid #a3a3a3; @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0)); - background-color: #aeaeae; + background-color: #aaaaaa; box-shadow: 0 1px 0 rgba(255, 255, 255, .2) inset; span, @@ -1270,10 +1307,32 @@ body.discussion { } } - .blank-slate h1 { - margin-top: 195px; - text-align: center; - color: #ccc; + .blank-slate { + //nothing here + .section { + border-bottom: 1px solid #ccc; + margin-top: 15px; + } + .home-header { + //nothing here + } + + .home-title { + font-size: 18px; + color: #000; + margin-bottom: 5px; + } + .home-description { + font-size: 12px; + line-height: 1; + margin-bottom: 10px; + } + .home-stats { + //nothing + } + .home-emailsettings { + //nothing here + } } .blank-slate, diff --git a/lms/templates/discussion/_filter_dropdown.html b/lms/templates/discussion/_filter_dropdown.html index fef4abb11f..1f59d4235d 100644 --- a/lms/templates/discussion/_filter_dropdown.html +++ b/lms/templates/discussion/_filter_dropdown.html @@ -11,12 +11,12 @@ <%def name="render_entry(entries, entry)"> -
  • ${entry}
  • +
  • ${entry}
  • <%def name="render_category(categories, category)">
  • - ${category} + ${category} @@ -29,12 +29,12 @@ diff --git a/lms/templates/mktg_iframe.html b/lms/templates/mktg_iframe.html index 6d02f3fcc5..abaf466785 100644 --- a/lms/templates/mktg_iframe.html +++ b/lms/templates/mktg_iframe.html @@ -27,14 +27,7 @@ From 049794463a8cab15d2d3cabca106d3a2bef03e9d Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 24 Jul 2013 11:52:25 -0400 Subject: [PATCH 155/556] edx.org: revising base link color variable to use darker value for accessibility --- lms/static/sass/base/_variables.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index b7dd620f8a..7c209705e8 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -118,7 +118,7 @@ $border-color-3: rgb(100,100,100); $border-color-4: rgb(252,252,252); $link-color: $blue; -$link-color-d1: $m-blue; +$link-color-d1: $m-blue-d2; $link-hover: $pink; $site-status-color: $pink; From d61e4e50ec2ebfb46d28f4f9c682a3163ece481c Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 24 Jul 2013 14:56:27 -0400 Subject: [PATCH 156/556] edx.org: abstracting new edx.org button extends and colors with other themes in mind --- lms/static/sass/_shame.scss | 64 +++++++++++------------ lms/static/sass/base/_variables.scss | 48 ++++++++++++++++- lms/static/sass/multicourse/_account.scss | 2 +- lms/static/sass/shared/_header.scss | 2 +- lms/templates/mktg_iframe.html | 3 +- 5 files changed, 82 insertions(+), 37 deletions(-) diff --git a/lms/static/sass/_shame.scss b/lms/static/sass/_shame.scss index f67683afe4..8348a774d4 100644 --- a/lms/static/sass/_shame.scss +++ b/lms/static/sass/_shame.scss @@ -35,7 +35,7 @@ } // primary button -.m-btn-primary { +.m-btn-base { @extend .m-btn; @extend .m-btn-edged; border: none; @@ -46,67 +46,67 @@ letter-spacing: 0; &.disabled, &[disabled], &.is-disabled { - background: $m-gray-d3; + background: $action-primary-disabled-bg; &:hover { - background: $m-gray-d3 !important; // needed for IE currently + background: $action-primary-disabled-bg !important; // needed for IE currently } } } -// blue primary button -.m-btn-primary-blue { - @extend .m-btn-primary; - box-shadow: 0 2px 1px 0 $m-blue-d4; - background: $m-blue-d3; - color: $white; +// primary button +.m-btn-primary { + @extend .m-btn-base; + box-shadow: 0 2px 1px 0 $action-primary-shadow; + background: $action-primary-bg; + color: $action-primary-fg; &:hover, &:active { - background: $m-blue-d1; + background: $action-primary-focused-bg; } &.current, &.active { - box-shadow: inset 0 2px 1px 1px $m-blue-d2; - background: $m-blue; - color: $m-blue-d2; + box-shadow: inset 0 2px 1px 1px $action-primary-active-shadow; + background: $action-primary-active-bg; + color: $action-primary-active-fg; &:hover, &:active { - box-shadow: inset 0 2px 1px 1px $m-blue-d3; - color: $m-blue-d3; + box-shadow: inset 0 2px 1px 1px $action-primary-active-focused-shadow; + color: $action-primary-active-focused-fg; } } &.disabled, &[disabled] { box-shadow: none; - background: $m-gray-d3; // needed for IE currently + background: $action-primary-disabled-bg; // needed for IE currently } } -// pink primary button -.m-btn-primary-pink { - @extend .m-btn-primary; - box-shadow: 0 2px 1px 0 $m-pink-d2; - background: $m-pink; - color: $white; +// secondary button +.m-btn-secondary { + @extend .m-btn-base; + box-shadow: 0 2px 1px 0 $action-secondary-shadow; + background: $action-secondary-bg; + color: $action-secondary-fg; &:hover, &:active { - background: $m-pink-l3; + background: $action-secondary-focused-bg; } &.current, &.active { - box-shadow: inset 0 2px 1px 1px $m-pink-d1; - background: $m-pink-l2; - color: $m-pink-d1; + box-shadow: inset 0 2px 1px 1px $action-secondary-active-shadow; + background: $action-secondary-active-bg; + color: $action-secondary-active-fg; &:hover, &:active { - box-shadow: inset 0 2px 1px 1px $m-pink-d2; - color: $m-pink-d3; + box-shadow: inset 0 2px 1px 1px $action-secondary-active-focused-shadow; + color: $action-secondary-active-focused-fg; } } &.disabled, &[disabled] { box-shadow: none; - background: $m-gray-d3; // needed for IE currently + background: $action-secondary-disabled-bg; // needed for IE currently } } @@ -154,20 +154,20 @@ // register or access courseware &.action-register, &.access-courseware { - @extend .m-btn-primary-blue; + @extend .m-btn-primary; display: block; } // already registered but course not started or registration is closed &.is-registered, &.registration-closed { - @extend .m-btn-primary-pink; + @extend .m-btn-secondary; pointer-events: none !important; display: block; } // coming soon &.coming-soon { - @extend .m-btn-primary-pink; + @extend .m-btn-secondary; pointer-events: none !important; outline: none; display: block; diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 7c209705e8..48c62bbb4c 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -69,7 +69,6 @@ $m-pink-d3: #771C44; $m-base-font-size: em(15); - $base-font-color: rgb(60,60,60); $baseFontColor: rgb(60,60,60); $lighter-base-font-color: rgb(100,100,100); @@ -88,10 +87,57 @@ $courseware-footer-border: none; $courseware-footer-shadow: none; $courseware-footer-margin: 0px; + +// actions $button-bg-image: linear-gradient(#fff 0%, rgb(250,250,250) 50%, rgb(237,237,237) 50%, rgb(220,220,220) 100%); $button-bg-color: transparent; $button-bg-hover-color: #fff; +// actions - primary +$action-primary-bg: $m-blue-d3; +$action-primary-fg: $white; +$action-primary-shadow: $m-blue-d4; + +// focused - hover/active pseudo states +$action-primary-focused-bg: $m-blue-d1; +$action-primary-focused-fg: $white; + +// current or active navigation item +$action-primary-active-bg: $m-blue; +$action-primary-active-fg: $m-blue-d3; +$action-primary-active-shadow: $m-blue-d2; +$action-primary-active-focused-fg: $m-blue-d4; +$action-primary-active-focused-shadow: $m-blue-d3; + +// disabled +$action-primary-disabled-bg: $m-gray-d3; +$action-prmary-disabled-fg: $white; + + + +// actions - secondary +$action-secondary-bg: $m-pink; +$action-secondary-fg: $white; +$action-secondary-shadow: $m-pink-d2; + +// focused - hover/active pseudo states +$action-secondary-focused-bg: $m-pink-l3; +$action-secondary-focused-fg: $white; + +// current or active navigation item +$action-secondary-active-bg: $m-pink-l2; +$action-secondary-active-fg: $m-pink-d1; +$action-secondary-active-shadow: $m-pink-d1; +$action-secondary-active-focused-fg: $m-pink-d3; +$action-secondary-active-focused-shadow: $m-pink-d2; + +// disabled +$action-secondary-disabled-bg: $m-gray-d3; +$action-secondary-disabled-fg: $white; + + + + $faded-hr-image-1: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1) 50%, rgba(200,200,200, 0)); $faded-hr-image-2: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1)); $faded-hr-image-3: linear-gradient(180deg, rgba(200,200,200, 1) 0%, rgba(200,200,200, 0)); diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index 0daaf8c0ca..2ec9f50dba 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -390,7 +390,7 @@ @include clearfix(); button[type="submit"] { - @extend .m-btn-primary-blue; + @extend .m-btn-primary; &:disabled, &.is-disabled { opacity: 0.3; diff --git a/lms/static/sass/shared/_header.scss b/lms/static/sass/shared/_header.scss index 90aa7c6fa6..3f2daccf52 100644 --- a/lms/static/sass/shared/_header.scss +++ b/lms/static/sass/shared/_header.scss @@ -279,7 +279,7 @@ header.global { display: inline-block; .cta { - @extend .m-btn-primary-blue; + @extend .m-btn-primary; } } diff --git a/lms/templates/mktg_iframe.html b/lms/templates/mktg_iframe.html index abaf466785..97a23d0e5f 100644 --- a/lms/templates/mktg_iframe.html +++ b/lms/templates/mktg_iframe.html @@ -27,8 +27,7 @@ From 2ee140eb79bc14ad6b28667045b88b9747ab451d Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 24 Jul 2013 15:16:32 -0400 Subject: [PATCH 157/556] edx.org: syncing up capitalization of register/login submit button copy --- lms/templates/register.html | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lms/templates/register.html b/lms/templates/register.html index 57a9ffa843..ec6bdd81bb 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -69,7 +69,7 @@ $submitButton. removeClass('is-disabled'). removeProp('disabled'). - html('Create my ${settings.PLATFORM_NAME} Account'); + html('Create My ${settings.PLATFORM_NAME} Account'); } else { $submitButton. @@ -141,32 +141,32 @@
      - + % if ask_for_email:
    1. - + % endif - +
    2. Will be shown in any discussions or forums you participate in
    3. - + % if ask_for_fullname: - +
    4. Needed for any certificates you may earn (cannot be changed later)
    5. - + % endif - +
    % endif @@ -282,7 +282,7 @@

    - + % endif ## TODO: Use a %block tag or something to allow themes to From 393aa1da267e440af553b83985f8aba04a47c24c Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 29 Jul 2013 12:23:19 -0400 Subject: [PATCH 158/556] Ensure parent update of children decaches correctly --- common/lib/xmodule/xmodule/x_module.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 5c8324e2ee..f53a2b5f8b 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -616,10 +616,13 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): new_block = system.xblock_from_json(cls, usage_id, json_data) if parent_xblock is not None: - parent_xblock.children.append(new_block) - # decache pending children field settings (Note, truly persisting at this point would break b/c - # persistence assumes children is a list of ids not actual xblocks) - parent_xblock.save() + children = parent_xblock.children + children.append(new_block) + # trigger setter method by using top level field access + parent_xblock.children = children + # decache pending children field settings (Note, truly persisting at this point would break b/c + # persistence assumes children is a list of ids not actual xblocks) + parent_xblock.save() return new_block @classmethod From e648347381d2e6dc96c22dfdec2c4fd0514a3dbb Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 22 Jul 2013 09:06:38 -0400 Subject: [PATCH 159/556] Default branch info for release email script --- scripts/release-email-list.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/release-email-list.sh b/scripts/release-email-list.sh index f54018f4f5..246a449273 100755 --- a/scripts/release-email-list.sh +++ b/scripts/release-email-list.sh @@ -1,9 +1,20 @@ #! /bin/bash +# Usage: release-email-list.sh [$PREVIOUS_COMMIT [$CURRENT_COMMIT]] +# +# Prints a list of email addresses and a Confluence style wiki table +# that indicate all of the changes made between $PREVIOUS_COMMIT and $CURRENT_COMMIT +# +# PREVIOUS_COMMIT defaults to origin/release +# CURRENT_COMMIT defaults to HEAD -LOG_CMD="git --no-pager log $1..$2" +BASE=${1:-origin/release} +CURRENT=${2:-HEAD} +LOG_CMD="git --no-pager log $BASE..$CURRENT" RESPONSIBLE=$(sort -u <($LOG_CMD --format='tformat:%ae' && $LOG_CMD --format='tformat:%ce')) +echo "Comparing $BASE to $CURRENT" + echo "~~~~ Email ~~~~~" echo -n 'To: ' From 087d35c95e7bfda31b406b9a3f0064b96955e0dc Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 29 Jul 2013 13:01:37 -0400 Subject: [PATCH 160/556] Studio course creator e-mails. --- CHANGELOG.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 68308980ad..51a98f2de7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Studio: Send e-mails to new Studio users (on edge only) when their course creator +status has changed. This will not be in use until the course creator table +is enabled. + LMS: Added user preferences (arbitrary user/key/value tuples, for which which user/key is unique) and a REST API for reading users and preferences. Access to the REST API is restricted by use of the From c240f6597d32b01093236cd0b49ee48060bb81dc Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 22 Jul 2013 13:26:08 -0400 Subject: [PATCH 161/556] Reformat JS --- cms/templates/manage_users.html | 89 ++++++++++++++++----------------- 1 file changed, 43 insertions(+), 46 deletions(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 22d57be41d..32d6522686 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -67,57 +67,51 @@ <%block name="jsextra"> From 21a32370df88195a07a360d84c12995459cffcad Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 22 Jul 2013 15:05:09 -0400 Subject: [PATCH 162/556] Reorganize URLs and views around course team Match other views better, saner URLs, more RESTful style, extensible for other roles --- cms/djangoapps/contentstore/views/user.py | 126 ++++++++++------------ cms/templates/manage_users.html | 32 ++++-- cms/templates/widgets/header.html | 2 +- cms/urls.py | 12 +-- 4 files changed, 83 insertions(+), 89 deletions(-) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 3a18448118..47dc35fcb4 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -1,7 +1,9 @@ +import json from django.conf import settings from django.core.exceptions import PermissionDenied -from django.core.urlresolvers import reverse +from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_http_methods from django.utils.translation import ugettext as _ from django.views.decorators.http import require_POST from django_future.csrf import ensure_csrf_cookie @@ -10,9 +12,9 @@ from django.core.context_processors import csrf from xmodule.modulestore.django import modulestore from contentstore.utils import get_url_reverse, get_lms_link_for_item -from util.json_request import expect_json, JsonResponse +from util.json_request import JsonResponse from auth.authz import STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role -from auth.authz import get_user_by_email, add_user_to_course_group, remove_user_from_course_group +from auth.authz import add_user_to_course_group, remove_user_from_course_group from course_creators.views import get_course_creator_status, add_user_with_status_unrequested, user_requested_access from .access import has_access @@ -60,10 +62,11 @@ def request_course_creator(request): @login_required @ensure_csrf_cookie -def manage_users(request, location): +def manage_users(request, org, course, name): ''' This view will return all CMS users who are editors for the specified course ''' + location = Location('i4x', org, course, 'course', name) # check that logged in user has permissions to this item if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): raise PermissionDenied() @@ -73,91 +76,72 @@ def manage_users(request, location): return render_to_response('manage_users.html', { 'context_course': course_module, 'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME), - 'add_user_postback_url': reverse('add_user', args=[location]).rstrip('/'), - 'remove_user_postback_url': reverse('remove_user', args=[location]).rstrip('/'), 'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), - 'request_user_id': request.user.id }) -@expect_json @login_required @ensure_csrf_cookie -def add_user(request, location): - ''' - This POST-back view will add a user - specified by email - to the list of editors for - the specified course - ''' - email = request.POST.get("email") - - if not email: - msg = { - 'Status': 'Failed', - 'ErrMsg': _('Please specify an email address.'), - } - return JsonResponse(msg, 400) - - # remove leading/trailing whitespace if necessary - email = email.strip() - - # check that logged in user has admin permissions to this course - if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): +@require_http_methods(("GET", "POST", "PUT", "DELETE")) +def course_team_user(request, org, course, name, email): + location = Location('i4x', org, course, 'course', name) + # check that logged in user has permissions to this item + if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): raise PermissionDenied() - user = get_user_by_email(email) - - # user doesn't exist?!? Return error. - if user is None: + try: + user = User.objects.get(email=email) + except: msg = { - 'Status': 'Failed', - 'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email), + "error": _("Could not find user by email address '{email}'.").format(email=email), } return JsonResponse(msg, 404) - # user exists, but hasn't activated account?!? + if request.method == "GET": + # just return info about the user + roles = set() + for group in user.groups.all(): + if not "_" in group.name: + continue + role, coursename = group.name.split("_", 1) + if coursename in (location.course, location.course_id): + roles.add(role) + msg = { + "email": user.email, + "active": user.is_active, + "roles": list(roles), + } + return JsonResponse(msg) + + # can't modify an inactive user if not user.is_active: msg = { - 'Status': 'Failed', - 'ErrMsg': _('User {email} has registered but has not yet activated his/her account.').format(email=email), + "error": _('User {email} has registered but has not yet activated his/her account.').format(email=email), } return JsonResponse(msg, 400) - # ok, we're cool to add to the course group - add_user_to_course_group(request.user, user, location, STAFF_ROLE_NAME) + # all other operations require the requesting user to specify a role -- + # or if no role is specified, default to "staff" + if "role" in request.POST: + role = request.POST["role"] + elif request.body: + try: + payload = json.loads(request.body) + except: + return JsonResponse({"error": _("malformed JSON")}, 400) + try: + role = payload["role"] + except KeyError: + return JsonResponse({"error": "`role` is required"}, 400) + else: + role = STAFF_ROLE_NAME - return JsonResponse({"Status": "OK"}) - - -@expect_json -@login_required -@ensure_csrf_cookie -def remove_user(request, location): - ''' - This POST-back view will remove a user - specified by email - from the list of editors for - the specified course - ''' - - email = request.POST["email"] - - # check that logged in user has admin permissions on this course - if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): - raise PermissionDenied() - - user = get_user_by_email(email) - if user is None: - msg = { - 'Status': 'Failed', - 'ErrMsg': _("Could not find user by email address '{email}'.").format(email=email), - } - return JsonResponse(msg, 404) - - # make sure we're not removing ourselves - if user.id == request.user.id: - raise PermissionDenied() - - remove_user_from_course_group(request.user, user, location, STAFF_ROLE_NAME) - - return JsonResponse({"Status": "OK"}) + if request.method in ("POST", "PUT"): + add_user_to_course_group(request.user, user, location, role) + return JsonResponse() + elif request.method == "DELETE": + remove_user_from_course_group(request.user, user, location, role) + return JsonResponse() def _get_course_creator_status(user): diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 32d6522686..45b4651983 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -1,4 +1,5 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> <%inherit file="base.html" /> <%block name="title">${_("Course Team Settings")} <%block name="bodyclass">is-signedin course users settings team @@ -46,12 +47,17 @@
      % for user in staff: -
    1. +
    2. ${user.username} ${user.email} %if allow_actions :
      - %if request_user_id != user.id: + %if request.user.id != user.id: %endif
      @@ -67,20 +73,25 @@ <%block name="jsextra"> From 6a9074e1851fea87b0a5edd0b077659575c5574a Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 10:17:16 -0400 Subject: [PATCH 164/556] Removed `get_url_reverse` function It was causing unit tests to fail, and it's a needless bit of abstraction that never should have existed in the first place. --- .../contentstore/tests/test_checklists.py | 8 +++- .../contentstore/tests/test_utils.py | 44 ------------------- cms/djangoapps/contentstore/utils.py | 32 -------------- cms/djangoapps/contentstore/views/assets.py | 7 ++- .../contentstore/views/checklist.py | 17 ++++++- cms/djangoapps/contentstore/views/user.py | 22 +++++++--- 6 files changed, 43 insertions(+), 87 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 02999f6567..6f8f102df8 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -1,5 +1,5 @@ """ Unit tests for checklist methods in views.py. """ -from contentstore.utils import get_modulestore, get_url_reverse +from contentstore.utils import get_modulestore from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.tests.factories import CourseFactory from django.core.urlresolvers import reverse @@ -38,7 +38,11 @@ class ChecklistTestCase(CourseTestCase): def test_get_checklists(self): """ Tests the get checklists method. """ - checklists_url = get_url_reverse('Checklists', self.course) + checklists_url = reverse("checklists", kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + }) response = self.client.get(checklists_url) self.assertContains(response, "Getting Started With Studio") payload = response.content diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index fec82db1bb..26c49843b5 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -72,50 +72,6 @@ class LMSLinksTestCase(TestCase): ) -class UrlReverseTestCase(ModuleStoreTestCase): - """ Tests for get_url_reverse """ - def test_course_page_names(self): - """ Test the defined course pages. """ - course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course') - - self.assertEquals( - '/manage_users/i4x://mitX/666/course/URL_Reverse_Course', - utils.get_url_reverse('ManageUsers', course) - ) - - self.assertEquals( - '/mitX/666/settings-details/URL_Reverse_Course', - utils.get_url_reverse('SettingsDetails', course) - ) - - self.assertEquals( - '/mitX/666/settings-grading/URL_Reverse_Course', - utils.get_url_reverse('SettingsGrading', course) - ) - - self.assertEquals( - '/mitX/666/course/URL_Reverse_Course', - utils.get_url_reverse('CourseOutline', course) - ) - - self.assertEquals( - '/mitX/666/checklists/URL_Reverse_Course', - utils.get_url_reverse('Checklists', course) - ) - - def test_unknown_passes_through(self): - """ Test that unknown values pass through. """ - course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course') - self.assertEquals( - 'foobar', - utils.get_url_reverse('foobar', course) - ) - self.assertEquals( - 'https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', - utils.get_url_reverse('https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about', course) - ) - - class ExtraPanelTabTestCase(TestCase): """ Tests adding and removing extra course tabs. """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 4973bddaca..a2e927ef46 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -188,38 +188,6 @@ def update_item(location, value): get_modulestore(location).update_item(location, value) -def get_url_reverse(course_page_name, course_module): - """ - Returns the course URL link to the specified location. This value is suitable to use as an href link. - - course_page_name should correspond to an attribute in CoursePageNames (for example, 'ManageUsers' - or 'SettingsDetails'), or else it will simply be returned. This method passes back unknown values of - course_page_names so that it can also be used for absolute (known) URLs. - - course_module is used to obtain the location, org, course, and name properties for a course, if - course_page_name corresponds to an attribute in CoursePageNames. - """ - url_name = getattr(CoursePageNames, course_page_name, None) - ctx_loc = course_module.location - - if CoursePageNames.ManageUsers == url_name: - return reverse(url_name, kwargs={"location": ctx_loc}) - elif url_name in [CoursePageNames.SettingsDetails, CoursePageNames.SettingsGrading, - CoursePageNames.CourseOutline, CoursePageNames.Checklists]: - return reverse(url_name, kwargs={'org': ctx_loc.org, 'course': ctx_loc.course, 'name': ctx_loc.name}) - else: - return course_page_name - - -class CoursePageNames: - """ Constants for pages that are recognized by get_url_reverse method. """ - ManageUsers = "manage_users" - SettingsDetails = "settings_details" - SettingsGrading = "settings_grading" - CourseOutline = "course_index" - Checklists = "checklists" - - def add_extra_panel_tab(tab_type, course): """ Used to add the panel tab to a course if it does not exist. diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 0bb9551ac9..6d371bef18 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -29,7 +29,6 @@ from xmodule.util.date_utils import get_default_time_display from xmodule.modulestore import InvalidLocationError from xmodule.exceptions import NotFoundError -from ..utils import get_url_reverse from .access import get_location_and_verify_access from util.json_request import JsonResponse @@ -320,7 +319,11 @@ def import_course(request, org, course, name): return render_to_response('import.html', { 'context_course': course_module, - 'successful_import_redirect_url': get_url_reverse('CourseOutline', course_module) + 'successful_import_redirect_url': reverse('course_index', kwargs={ + 'org': location.org, + 'course': location.course, + 'name': location.name, + }) }) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index bcf4a1a5b9..17f0a55565 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -4,12 +4,13 @@ from util.json_request import JsonResponse from django.http import HttpResponseBadRequest from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods +from django.core.urlresolvers import reverse from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response from xmodule.modulestore.inheritance import own_metadata -from ..utils import get_modulestore, get_url_reverse +from ..utils import get_modulestore from .access import get_location_and_verify_access from xmodule.course_module import CourseDescriptor @@ -96,10 +97,22 @@ def expand_checklist_action_urls(course_module): """ checklists = course_module.checklists modified = False + urlconf_map = { + "ManageUsers": "manage_users", + "SettingsDetails": "settings_details", + "SettingsGrading": "settings_grading", + "CourseOutline": "course_index", + "Checklists": "checklists", + } for checklist in checklists: if not checklist.get('action_urls_expanded', False): for item in checklist.get('items'): - item['action_url'] = get_url_reverse(item.get('action_url'), course_module) + urlconf_name = urlconf_map.get(item.get('action_url')) + item['action_url'] = reverse(urlconf_name, kwargs={ + 'org': course_module.location.org, + 'course': course_module.location.course, + 'name': course_module.location.name, + }) checklist['action_urls_expanded'] = True modified = True diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index ee4e4e435a..c5d642e207 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -1,6 +1,7 @@ import json from django.conf import settings from django.core.exceptions import PermissionDenied +from django.core.urlresolvers import reverse from django.contrib.auth.models import User, Group from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods @@ -11,7 +12,7 @@ from mitxmako.shortcuts import render_to_response from django.core.context_processors import csrf from xmodule.modulestore.django import modulestore -from contentstore.utils import get_url_reverse, get_lms_link_for_item +from contentstore.utils import get_lms_link_for_item from util.json_request import JsonResponse from auth.authz import ( STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_users_in_course_group_by_role, @@ -40,11 +41,22 @@ def index(request): and course.location.name != '') courses = filter(course_filter, courses) + def format_course_for_view(course): + return ( + course.display_name, + reverse("course_index", kwargs={ + 'org': course.location.org, + 'course': course.location.course, + 'name': course.location.name, + }), + get_lms_link_for_item( + course.location, + course_id=course.location.course_id, + ), + ) + return render_to_response('index.html', { - 'courses': [(course.display_name, - get_url_reverse('CourseOutline', course), - get_lms_link_for_item(course.location, course_id=course.location.course_id)) - for course in courses], + 'courses': [format_course_for_view(c) for c in courses], 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), From b835f7c3a3ef0aa452935db3ee9bbdf40be33e78 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 10:38:45 -0400 Subject: [PATCH 165/556] Update a manage_user reverse call --- cms/templates/settings.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 1dde9b6c0d..1f5d89b2b9 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -262,7 +262,7 @@ from contentstore import utils From 724ef2e1e5a8cff9656960f46b6e05dcb3317560 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 11:10:47 -0400 Subject: [PATCH 166/556] Fixing test failures --- .../contentstore/tests/test_contentstore.py | 4 +- .../contentstore/tests/test_users.py | 195 ------------------ .../contentstore/views/checklist.py | 5 +- cms/templates/settings_advanced.html | 2 +- cms/templates/settings_graders.html | 2 +- 5 files changed, 9 insertions(+), 199 deletions(-) delete mode 100644 cms/djangoapps/contentstore/tests/test_users.py diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index a51110163d..0ba4c49874 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1167,7 +1167,9 @@ class ContentStoreTest(ModuleStoreTestCase): # manage users resp = self.client.get(reverse('manage_users', - kwargs={'location': loc.url()})) + kwargs={'org': loc.org, + 'course': loc.course, + 'name': loc.name})) self.assertEqual(200, resp.status_code) # course info diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py deleted file mode 100644 index 8fea4004dd..0000000000 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Tests for user.py. -""" -import json -import mock -from .utils import CourseTestCase -from django.core.urlresolvers import reverse -from contentstore.views.user import _get_course_creator_status -from course_creators.views import add_user_with_status_granted -from course_creators.admin import CourseCreatorAdmin -from course_creators.models import CourseCreator - -from django.http import HttpRequest -from django.contrib.auth.models import User -from django.contrib.admin.sites import AdminSite - - -class UsersTestCase(CourseTestCase): - def setUp(self): - super(UsersTestCase, self).setUp() - self.url = reverse("add_user", kwargs={"location": ""}) - - def test_empty(self): - resp = self.client.post(self.url) - self.assertEqual(resp.status_code, 400) - content = json.loads(resp.content) - self.assertEqual(content["Status"], "Failed") - - -class IndexCourseCreatorTests(CourseTestCase): - """ - Tests the various permutations of course creator status. - """ - def setUp(self): - super(IndexCourseCreatorTests, self).setUp() - - self.index_url = reverse("index") - self.request_access_url = reverse("request_course_creator") - - # Disable course creation takes precedence over enable creator group. I have enabled the - # latter to make this clear. - self.disable_course_creation = { - "DISABLE_COURSE_CREATION": True, - "ENABLE_CREATOR_GROUP": True, - 'STUDIO_REQUEST_EMAIL': 'mark@marky.mark', - } - - self.enable_creator_group = {"ENABLE_CREATOR_GROUP": True} - - self.admin = User.objects.create_user('Mark', 'mark+courses@edx.org', 'foo') - self.admin.is_staff = True - - def test_get_course_creator_status_disable_creation(self): - # DISABLE_COURSE_CREATION is True (this is the case on edx, where we have a marketing site). - # Only edx staff can create courses. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation): - self.assertTrue(self.user.is_staff) - self.assertEquals('granted', _get_course_creator_status(self.user)) - self._set_user_non_staff() - self.assertFalse(self.user.is_staff) - self.assertEquals('disallowed_for_this_site', _get_course_creator_status(self.user)) - - def test_get_course_creator_status_default_cause(self): - # Neither ENABLE_CREATOR_GROUP nor DISABLE_COURSE_CREATION are enabled. Anyone can create a course. - self.assertEquals('granted', _get_course_creator_status(self.user)) - self._set_user_non_staff() - self.assertEquals('granted', _get_course_creator_status(self.user)) - - def test_get_course_creator_status_creator_group(self): - # ENABLE_CREATOR_GROUP is True. This is the case on edge. - # Only staff members and users who have been granted access can create courses. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group): - # Staff members can always create courses. - self.assertEquals('granted', _get_course_creator_status(self.user)) - # Non-staff must request access. - self._set_user_non_staff() - self.assertEquals('unrequested', _get_course_creator_status(self.user)) - # Staff user requests access. - self.client.post(self.request_access_url) - self.assertEquals('pending', _get_course_creator_status(self.user)) - - def test_get_course_creator_status_creator_group_granted(self): - # ENABLE_CREATOR_GROUP is True. This is the case on edge. - # Check return value for a non-staff user who has been granted access. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group): - self._set_user_non_staff() - add_user_with_status_granted(self.admin, self.user) - self.assertEquals('granted', _get_course_creator_status(self.user)) - - def test_get_course_creator_status_creator_group_denied(self): - # ENABLE_CREATOR_GROUP is True. This is the case on edge. - # Check return value for a non-staff user who has been denied access. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group): - self._set_user_non_staff() - self._set_user_denied() - self.assertEquals('denied', _get_course_creator_status(self.user)) - - def test_disable_course_creation_enabled_non_staff(self): - # Test index page content when DISABLE_COURSE_CREATION is True, non-staff member. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation): - self._set_user_non_staff() - self._assert_cannot_create() - - def test_disable_course_creation_enabled_staff(self): - # Test index page content when DISABLE_COURSE_CREATION is True, staff member. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.disable_course_creation): - resp = self._assert_can_create() - self.assertFalse('Email staff to create course' in resp.content) - - def test_can_create_by_default(self): - # Test index page content with neither ENABLE_CREATOR_GROUP nor DISABLE_COURSE_CREATION enabled. - # Anyone can create a course. - self._assert_can_create() - self._set_user_non_staff() - self._assert_can_create() - - def test_course_creator_group_enabled(self): - # Test index page content with ENABLE_CREATOR_GROUP True. - # Staff can always create a course, others must request access. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group): - # Staff members can always create courses. - self._assert_can_create() - - # Non-staff case. - self._set_user_non_staff() - resp = self._assert_cannot_create() - self.assertTrue(self.request_access_url in resp.content) - - # Now request access. - self.client.post(self.request_access_url) - - # Still cannot create a course, but the "request access button" is no longer there. - resp = self._assert_cannot_create() - self.assertFalse(self.request_access_url in resp.content) - self.assertTrue('has-status is-pending' in resp.content) - - def test_course_creator_group_granted(self): - # Test index page content with ENABLE_CREATOR_GROUP True, non-staff member with access granted. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group): - self._set_user_non_staff() - add_user_with_status_granted(self.admin, self.user) - self._assert_can_create() - - def test_course_creator_group_denied(self): - # Test index page content with ENABLE_CREATOR_GROUP True, non-staff member with access denied. - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group): - self._set_user_non_staff() - self._set_user_denied() - resp = self._assert_cannot_create() - self.assertFalse(self.request_access_url in resp.content) - self.assertTrue('has-status is-denied' in resp.content) - - def _assert_can_create(self): - """ - Helper method that posts to the index page and checks that the user can create a course. - - Returns the response from the post. - """ - resp = self.client.post(self.index_url) - self.assertTrue('new-course-button' in resp.content) - self.assertFalse(self.request_access_url in resp.content) - self.assertFalse('Email staff to create course' in resp.content) - return resp - - def _assert_cannot_create(self): - """ - Helper method that posts to the index page and checks that the user cannot create a course. - - Returns the response from the post. - """ - resp = self.client.post(self.index_url) - self.assertFalse('new-course-button' in resp.content) - return resp - - def _set_user_non_staff(self): - """ - Sets user as non-staff. - """ - self.user.is_staff = False - self.user.save() - - def _set_user_denied(self): - """ - Sets course creator status to denied in admin table. - """ - self.table_entry = CourseCreator(user=self.user) - self.table_entry.save() - - self.deny_request = HttpRequest() - self.deny_request.user = self.admin - - self.creator_admin = CourseCreatorAdmin(self.table_entry, AdminSite()) - - self.table_entry.state = CourseCreator.DENIED - self.creator_admin.save_model(self.deny_request, self.table_entry, None, True) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index 17f0a55565..74f0a33769 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -107,7 +107,10 @@ def expand_checklist_action_urls(course_module): for checklist in checklists: if not checklist.get('action_urls_expanded', False): for item in checklist.get('items'): - urlconf_name = urlconf_map.get(item.get('action_url')) + action_url = item.get('action_url') + if action_url not in urlconf_map: + continue + urlconf_name = urlconf_map[action_url] item['action_url'] = reverse(urlconf_name, kwargs={ 'org': course_module.location.org, 'course': course_module.location.course, diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index e1b1913c87..32f22712a8 100644 --- a/cms/templates/settings_advanced.html +++ b/cms/templates/settings_advanced.html @@ -96,7 +96,7 @@ editor.render(); % endif diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index d9040009cc..f3a4584a26 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -140,7 +140,7 @@ from contentstore import utils From 97a02d415f2789d317f5686a08020458514bda7d Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 12:55:58 -0400 Subject: [PATCH 167/556] Make assertion failure message more understandable --- .../features/component_settings_editor_helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 2b206e4466..8a8f6deb04 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -16,7 +16,11 @@ def create_component_instance(step, component_button_css, category, if has_multiple_templates: click_component_from_menu(category, boilerplate, expected_css) - assert_equal(1, len(world.css_find(expected_css))) + assert_equal( + 1, + len(world.css_find(expected_css)), + "Component instance with css {css} was not created successfully".format(css=expected_css)) + @world.absorb def click_new_component_button(step, component_button_css): From f438552b3b9dd13ad92ffec34971a7963915a91a Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 15:27:33 -0400 Subject: [PATCH 168/556] Added unit tests for new course team API --- .../contentstore/tests/test_users.py | 175 ++++++++++++++++++ cms/djangoapps/contentstore/views/user.py | 52 ++++-- cms/templates/manage_users.html | 11 +- 3 files changed, 211 insertions(+), 27 deletions(-) create mode 100644 cms/djangoapps/contentstore/tests/test_users.py diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py new file mode 100644 index 0000000000..82f511d6ab --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -0,0 +1,175 @@ +import json +from .utils import CourseTestCase +from django.contrib.auth.models import User, Group +from django.core.urlresolvers import reverse +from auth.authz import get_course_groupname_for_role + + +class UsersTestCase(CourseTestCase): + def setUp(self): + super(UsersTestCase, self).setUp() + self.ext_user = User.objects.create_user( + "joe", "joe@comedycentral.com", "haha") + self.ext_user.is_active = True + self.ext_user.is_staff = False + self.ext_user.save() + self.inactive_user = User.objects.create_user( + "carl", "carl@comedycentral.com", "haha") + self.inactive_user.is_active = False + self.inactive_user.is_staff = False + self.inactive_user.save() + + self.index_url = reverse("manage_users", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + }) + self.detail_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.ext_user.email, + }) + self.inactive_detail_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.inactive_user.email, + }) + self.invalid_detail_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": "nonexistent@user.com", + }) + self.staff_groupname = get_course_groupname_for_role(self.course.location, "staff") + self.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor") + + def test_index(self): + resp = self.client.get(self.index_url) + self.assertNotContains(resp, self.ext_user.email) + + def test_detail(self): + resp = self.client.get(self.detail_url) + self.assertEqual(resp.status_code, 200) + result = json.loads(resp.content) + self.assertEqual(result["role"], None) + self.assertTrue(result["active"]) + + def test_detail_inactive(self): + resp = self.client.get(self.inactive_detail_url) + self.assert2XX(resp.status_code) + result = json.loads(resp.content) + self.assertFalse(result["active"]) + + def test_detail_invalid(self): + resp = self.client.get(self.invalid_detail_url) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_detail_post(self): + resp = self.client.post( + self.detail_url, + data={"role": None}, + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + # no content: should not be in any roles + self.assertNotIn(self.staff_groupname, groups) + self.assertNotIn(self.inst_groupname, groups) + + def test_detail_post_staff(self): + resp = self.client.post( + self.detail_url, + data=json.dumps({"role": "staff"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.staff_groupname, groups) + self.assertNotIn(self.inst_groupname, groups) + + def test_detail_post_instructor(self): + resp = self.client.post( + self.detail_url, + data=json.dumps({"role": "instructor"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertNotIn(self.staff_groupname, groups) + self.assertIn(self.inst_groupname, groups) + + def test_detail_post_missing_role(self): + resp = self.client.post( + self.detail_url, + data=json.dumps({"toys": "fun"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_detail_post_bad_json(self): + resp = self.client.post( + self.detail_url, + data="{foo}", + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_detail_post_no_json(self): + resp = self.client.post( + self.detail_url, + data={"role": "staff"}, + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.staff_groupname, groups) + self.assertNotIn(self.inst_groupname, groups) + + def test_detail_delete_staff(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.delete( + self.detail_url, + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertNotIn(self.staff_groupname, groups) + + def test_detail_delete_instructor(self): + group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.delete( + self.detail_url, + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertNotIn(self.inst_groupname, groups) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index c5d642e207..2b2f170617 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -111,20 +111,23 @@ def course_team_user(request, org, course, name, email): } return JsonResponse(msg, 404) + # role hierarchy: "instructor" has more permissions than "staff" (in a course) + roles = ["instructor", "staff"] + if request.method == "GET": # just return info about the user - roles = set() - for group in user.groups.all(): - if not "_" in group.name: - continue - role, coursename = group.name.split("_", 1) - if coursename in (location.course, location.course_id): - roles.add(role) msg = { "email": user.email, "active": user.is_active, - "roles": list(roles), + "role": None, } + # what's the highest role that this user has? + groupnames = set(g.name for g in user.groups.all()) + for role in roles: + role_groupname = get_course_groupname_for_role(location, role) + if role_groupname in groupnames: + msg["role"] = role + break return JsonResponse(msg) # can't modify an inactive user @@ -134,11 +137,14 @@ def course_team_user(request, org, course, name, email): } return JsonResponse(msg, 400) - # all other operations require the requesting user to specify a role -- - # or if no role is specified, default to "staff" - if not request.body: - role = STAFF_ROLE_NAME - else: + if request.method == "DELETE": + # remove all roles in this course from this user + for role in roles: + remove_user_from_course_group(request.user, user, location, role) + return JsonResponse() + + # all other operations require the requesting user to specify a role + if request.META.get("CONTENT_TYPE", "") == "application/json" and request.body: try: payload = json.loads(request.body) except: @@ -147,15 +153,21 @@ def course_team_user(request, org, course, name, email): role = payload["role"] except KeyError: return JsonResponse({"error": "`role` is required"}, 400) - groupname = get_course_groupname_for_role(location, role) - group = Group.objects.get_or_create(name=groupname) + else: + if not "role" in request.POST: + return JsonResponse({"error": "`role` is required"}, 400) + role = request.POST["role"] - if request.method in ("POST", "PUT"): + # make sure that the role group exists + groupname = get_course_groupname_for_role(location, role) + Group.objects.get_or_create(name=groupname) + + if role == "instructor": add_user_to_course_group(request.user, user, location, role) - return JsonResponse() - elif request.method == "DELETE": - remove_user_from_course_group(request.user, user, location, role) - return JsonResponse() + elif role == "staff": + add_user_to_course_group(request.user, user, location, role) + remove_user_from_course_group(request.user, user, location, "instructor") + return JsonResponse() def _get_course_creator_status(user): diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 69430cbbea..9a468664b5 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -140,9 +140,6 @@ type: 'DELETE', dataType: 'json', contentType: 'application/json', - data: JSON.stringify({ - role: 'staff', - }), complete: function() { location.reload(); } @@ -153,18 +150,18 @@ e.preventDefault() var type; if($(this).hasClass("add-admin")) { - type = 'POST'; + role = 'instructor'; } else { - type = 'DELETE'; + role = 'staff'; } var url = $(this).closest("li").data("url"); $.ajax({ url: url, - type: type, + type: 'POST', dataType: 'json', contentType: 'application/json', data: JSON.stringify({ - role: 'instructor', + role: role }), complete: function() { location.reload(); From 0682157477bbd1996c99261ce0b730a2ba41953f Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 15:50:44 -0400 Subject: [PATCH 169/556] Test manage_users view for user that is a member of the course team --- cms/djangoapps/contentstore/tests/test_users.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index 82f511d6ab..327bbfcf64 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -47,8 +47,18 @@ class UsersTestCase(CourseTestCase): def test_index(self): resp = self.client.get(self.index_url) + # ext_user is not currently a member of the course team, and so should + # not show up on the page. self.assertNotContains(resp, self.ext_user.email) + def test_index_member(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.get(self.index_url) + self.assertContains(resp, self.ext_user.email) + def test_detail(self): resp = self.client.get(self.detail_url) self.assertEqual(resp.status_code, 200) From c70bd5c908249de3557cc96b0307918c91c5ed32 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 16:07:43 -0400 Subject: [PATCH 170/556] Remove whitespace from email addresses on the course team page --- cms/templates/manage_users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 9a468664b5..1fa6a4d64a 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -93,7 +93,7 @@ var $newUserForm = $('.new-user-form'); $newUserForm.bind('submit', function(e) { e.preventDefault(); - var url = tplUserURL.replace("@@EMAIL@@", $('#email').val()) + var url = tplUserURL.replace("@@EMAIL@@", $('#email').val().trim()) $.ajax({ url: url, type: 'POST', From b6c69547de0432ca1d55d44b35d32ecfd461061f Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Tue, 23 Jul 2013 16:43:19 -0400 Subject: [PATCH 171/556] Check for instructor role before removing it --- cms/djangoapps/contentstore/views/user.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 2b2f170617..6945d75da4 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -166,7 +166,10 @@ def course_team_user(request, org, course, name, email): add_user_to_course_group(request.user, user, location, role) elif role == "staff": add_user_to_course_group(request.user, user, location, role) - remove_user_from_course_group(request.user, user, location, "instructor") + # should *not* be an instructor + inst_groupname = get_course_groupname_for_role(location, "instructor") + if any(g.name == inst_groupname for g in user.groups.all()): + remove_user_from_course_group(request.user, user, location, "instructor") return JsonResponse() From 79c554ba5b82d0ba18d4777d7235f634783da483 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 24 Jul 2013 09:52:59 -0400 Subject: [PATCH 172/556] course admin team: handle is_staff users A user with `is_staff=True` is treated as being in all groups. This is problematic when we care about the user's staff/instructor role for a course: you can't remove the instructor role. This commit changes the `is_user_in_course_group_role` function to allow the caller to specify that it should not check the `is_staff` attribute on the user. --- cms/djangoapps/auth/authz.py | 6 ++++-- cms/templates/manage_users.html | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 0f2e60dd6e..4923851445 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -178,10 +178,12 @@ def _remove_user_from_group(user, group_name): user.save() -def is_user_in_course_group_role(user, location, role): +def is_user_in_course_group_role(user, location, role, check_staff=True): if user.is_active and user.is_authenticated: # all "is_staff" flagged accounts belong to all groups - return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 + if check_staff and user.is_staff: + return True + return user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 return False diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 1fa6a4d64a..8baa9854c9 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -59,7 +59,7 @@ % if allow_actions:
      % if request.user.id != user.id: - % if is_user_in_course_group_role(user, context_course.location, 'instructor'): + % if is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False): <% admin_class = "remove-admin" %> <% admin_text = "Remove Admin" %> % else: From 42331464eda1887d9bb43499c67fc1ea45f83e43 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 24 Jul 2013 10:39:27 -0400 Subject: [PATCH 173/556] Can't remove last instructor of a course --- .../contentstore/tests/test_users.py | 60 +++++++++++++++++++ cms/djangoapps/contentstore/views/user.py | 46 ++++++++++---- cms/templates/manage_users.html | 26 ++++---- 3 files changed, 106 insertions(+), 26 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index 327bbfcf64..2fe88490ee 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -105,6 +105,29 @@ class UsersTestCase(CourseTestCase): self.assertIn(self.staff_groupname, groups) self.assertNotIn(self.inst_groupname, groups) + def test_detail_post_staff_other_inst(self): + inst_group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.user.groups.add(inst_group) + self.user.save() + + resp = self.client.post( + self.detail_url, + data=json.dumps({"role": "staff"}), + content_type="application/json", + HTTP_ACCEPT="application/json", + ) + self.assert2XX(resp.status_code) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.staff_groupname, groups) + self.assertNotIn(self.inst_groupname, groups) + # check that other user is unchanged + user = User.objects.get(email=self.user.email) + groups = [g.name for g in user.groups.all()] + self.assertNotIn(self.staff_groupname, groups) + self.assertIn(self.inst_groupname, groups) + def test_detail_post_instructor(self): resp = self.client.post( self.detail_url, @@ -171,7 +194,9 @@ class UsersTestCase(CourseTestCase): def test_detail_delete_instructor(self): group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.user.groups.add(group) self.ext_user.groups.add(group) + self.user.save() self.ext_user.save() resp = self.client.delete( @@ -183,3 +208,38 @@ class UsersTestCase(CourseTestCase): ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] self.assertNotIn(self.inst_groupname, groups) + + def test_delete_last_instructor(self): + group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.delete( + self.detail_url, + HTTP_ACCEPT="application/json", + ) + self.assertEqual(resp.status_code, 400) + result = json.loads(resp.content) + self.assertIn("error", result) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.inst_groupname, groups) + + def test_post_last_instructor(self): + group, _ = Group.objects.get_or_create(name=self.inst_groupname) + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.post( + self.detail_url, + data={"role": "staff"}, + HTTP_ACCEPT="application/json", + ) + self.assertEqual(resp.status_code, 400) + result = json.loads(resp.content) + self.assertIn("error", result) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.inst_groupname, groups) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 6945d75da4..b7fe313226 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -87,9 +87,15 @@ def manage_users(request, org, course, name): course_module = modulestore().get_item(location) + staff_groupname = get_course_groupname_for_role(location, "staff") + staff_group, __ = Group.objects.get_or_create(name=staff_groupname) + inst_groupname = get_course_groupname_for_role(location, "instructor") + inst_group, __ = Group.objects.get_or_create(name=inst_groupname) + return render_to_response('manage_users.html', { 'context_course': course_module, - 'staff': get_users_in_course_group_by_role(location, STAFF_ROLE_NAME), + 'staff': staff_group.user_set.all(), + 'instructors': inst_group.user_set.all(), 'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), }) @@ -137,8 +143,22 @@ def course_team_user(request, org, course, name, email): } return JsonResponse(msg, 400) + # make sure that the role groups exist + staff_groupname = get_course_groupname_for_role(location, "staff") + staff_group, __ = Group.objects.get_or_create(name=staff_groupname) + inst_groupname = get_course_groupname_for_role(location, "instructor") + inst_group, __ = Group.objects.get_or_create(name=inst_groupname) + if request.method == "DELETE": - # remove all roles in this course from this user + # remove all roles in this course from this user: but fail if the user + # is the last instructor in the course team + instructors = set(inst_group.user_set.all()) + if user in instructors and len(instructors) == 1: + msg = { + "error": _("You may not remove the last instructor from a course") + } + return JsonResponse(msg, 400) + for role in roles: remove_user_from_course_group(request.user, user, location, role) return JsonResponse() @@ -152,24 +172,26 @@ def course_team_user(request, org, course, name, email): try: role = payload["role"] except KeyError: - return JsonResponse({"error": "`role` is required"}, 400) + return JsonResponse({"error": _("`role` is required")}, 400) else: if not "role" in request.POST: - return JsonResponse({"error": "`role` is required"}, 400) + return JsonResponse({"error": _("`role` is required")}, 400) role = request.POST["role"] - # make sure that the role group exists - groupname = get_course_groupname_for_role(location, role) - Group.objects.get_or_create(name=groupname) - if role == "instructor": add_user_to_course_group(request.user, user, location, role) elif role == "staff": - add_user_to_course_group(request.user, user, location, role) - # should *not* be an instructor - inst_groupname = get_course_groupname_for_role(location, "instructor") - if any(g.name == inst_groupname for g in user.groups.all()): + # if we're trying to downgrade a user from "instructor" to "staff", + # make sure we have at least one other instructor in the course team. + instructors = set(inst_group.user_set.all()) + if user in instructors: + if len(instructors) == 1: + msg = { + "error": _("You may not remove the last instructor from a course") + } + return JsonResponse(msg, 400) remove_user_from_course_group(request.user, user, location, "instructor") + add_user_to_course_group(request.user, user, location, role) return JsonResponse() diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 8baa9854c9..41738b1e6d 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -39,9 +39,9 @@
      - - - + + +
      %endif @@ -58,15 +58,13 @@ ${user.email} % if allow_actions:
      - % if request.user.id != user.id: - % if is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False): - <% admin_class = "remove-admin" %> - <% admin_text = "Remove Admin" %> - % else: - <% admin_class = "add-admin" %> - <% admin_text = "Add Admin" %> - % endif - ${admin_text} + <% is_instuctor = is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False) %> + % if is_instuctor and len(instructors) == 1: + Admin + % else: + ${_("Remove Admin") if is_instuctor else _("Add Admin")} + % endif + % if request.user.id != user.id: ## can't remove yourself % endif
      @@ -146,10 +144,10 @@ }); }); - $(".toggle-admin").click(function(e) { + $(".toggle-admin-role").click(function(e) { e.preventDefault() var type; - if($(this).hasClass("add-admin")) { + if($(this).hasClass("add-admin-role")) { role = 'instructor'; } else { role = 'staff'; From a1b44afda343780f932b244804c47ea5ed3ae9a2 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 24 Jul 2013 10:54:34 -0400 Subject: [PATCH 174/556] Only instructors may make other instructors on a course --- .../contentstore/tests/test_users.py | 74 +++++++++++++++++++ cms/djangoapps/contentstore/views/user.py | 26 ++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index 2fe88490ee..4b9dcf487f 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -243,3 +243,77 @@ class UsersTestCase(CourseTestCase): ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] self.assertIn(self.inst_groupname, groups) + + def test_permission_denied_self(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + + self_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.user.email, + }) + + resp = self.client.post( + self_url, + data={"role": "instructor"}, + HTTP_ACCEPT="application/json", + ) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_permission_denied_other(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + + resp = self.client.post( + self.detail_url, + data={"role": "instructor"}, + HTTP_ACCEPT="application/json", + ) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + + def test_staff_can_delete_self(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + + self_url = reverse("course_team_user", kwargs={ + "org": self.course.location.org, + "course": self.course.location.course, + "name": self.course.location.name, + "email": self.user.email, + }) + + resp = self.client.delete(self_url) + self.assert2XX(resp.status_code) + # reload user from DB + user = User.objects.get(email=self.user.email) + groups = [g.name for g in user.groups.all()] + self.assertNotIn(self.staff_groupname, groups) + + def test_staff_cannot_delete_other(self): + group, _ = Group.objects.get_or_create(name=self.staff_groupname) + self.user.groups.add(group) + self.user.is_staff = False + self.user.save() + self.ext_user.groups.add(group) + self.ext_user.save() + + resp = self.client.delete(self.detail_url) + self.assert4XX(resp.status_code) + result = json.loads(resp.content) + self.assertIn("error", result) + # reload user from DB + ext_user = User.objects.get(email=self.ext_user.email) + groups = [g.name for g in ext_user.groups.all()] + self.assertIn(self.staff_groupname, groups) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index b7fe313226..7e1cca2370 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -106,8 +106,17 @@ def manage_users(request, org, course, name): def course_team_user(request, org, course, name, email): location = Location('i4x', org, course, 'course', name) # check that logged in user has permissions to this item - if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): - raise PermissionDenied() + if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): + # instructors have full permissions + pass + elif has_access(request.user, location, role=STAFF_ROLE_NAME) and email == request.user.email: + # staff can only affect themselves + pass + else: + msg = { + "error": _("Insufficient permissions") + } + return JsonResponse(msg, 400) try: user = User.objects.get(email=email) @@ -153,14 +162,18 @@ def course_team_user(request, org, course, name, email): # remove all roles in this course from this user: but fail if the user # is the last instructor in the course team instructors = set(inst_group.user_set.all()) + staff = set(staff_group.user_set.all()) if user in instructors and len(instructors) == 1: msg = { "error": _("You may not remove the last instructor from a course") } return JsonResponse(msg, 400) - for role in roles: - remove_user_from_course_group(request.user, user, location, role) + if user in instructors: + user.groups.remove(inst_group) + if user in staff: + user.groups.remove(staff_group) + user.save() return JsonResponse() # all other operations require the requesting user to specify a role @@ -179,6 +192,11 @@ def course_team_user(request, org, course, name, email): role = request.POST["role"] if role == "instructor": + if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): + msg = { + "error": _("Only instructors may create other instructors") + } + return JsonResponse(msg, 400) add_user_to_course_group(request.user, user, location, role) elif role == "staff": # if we're trying to downgrade a user from "instructor" to "staff", From 8a10695d7eed4a8bea2980d0cdb7cdbd0eae5582 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 24 Jul 2013 11:20:59 -0400 Subject: [PATCH 175/556] Extend runone script to accept pdb flags --- scripts/runone.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/runone.py b/scripts/runone.py index b403b09ff9..8baf6790b8 100755 --- a/scripts/runone.py +++ b/scripts/runone.py @@ -19,6 +19,8 @@ def find_full_path(path_to_file): def main(argv): parser = argparse.ArgumentParser(description="Run just one test") parser.add_argument('--nocapture', '-s', action='store_true', help="Don't capture stdout (any stdout output will be printed immediately)") + parser.add_argument('--pdb', action='store_true', help="Use pdb for test errors") + parser.add_argument('--pdb-fail', action='store_true', help="Use pdb for test failures") parser.add_argument('words', metavar="WORDS", nargs='+', help="The description of a test failure, like 'ERROR: test_set_missing_field (courseware.tests.test_model_data.TestStudentModuleStorage)'") args = parser.parse_args(argv) @@ -54,6 +56,10 @@ def main(argv): django_args = ["./manage.py", system, "--settings", "test", "test"] if args.nocapture: django_args.append("-s") + if args.pdb: + django_args.append("--pdb") + if args.pdb_fail: + django_args.append("--pdb-fail") django_args.append(test_spec) print " ".join(django_args) From 4a2b5519ba2043cd3962739a661c9e87696ffe9f Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 25 Jul 2013 12:59:03 -0400 Subject: [PATCH 176/556] Studio: styles new user role controls and revamps course team UI --- cms/static/sass/elements/_forms.scss | 165 ++++++++++++++++++++++ cms/static/sass/elements/_icons.scss | 41 +++++- cms/static/sass/views/_textbooks.scss | 2 +- cms/static/sass/views/_users.scss | 195 ++++++++++++++++++++++---- cms/templates/manage_users.html | 164 +++++++++++++++------- 5 files changed, 492 insertions(+), 75 deletions(-) diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 9907b05995..3d11db02c3 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -95,6 +95,171 @@ form { // ==================== +// forms - archetype - add a new X form +.new-form { + @extend .ui-window; + @include box-sizing(border-box); + position: relative; + width: 100%; + margin-bottom: ($baseline*2); + border-radius: 2px; + background: $white; + + .wrapper-form { + padding: $baseline ($baseline*1.5); + } + + .title { + @extend .t-title4; + font-weight: 700; + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l4; + padding-bottom: ($baseline/2); + } + + fieldset { + margin-bottom: $baseline; + } + + // form elements - need to make this more universal + .form-fields { + @extend .cont-no-list; + + .field { + margin: 0 0 ($baseline*0.75) 0; + + &:last-child { + margin-bottom: 0; + } + + &.required { + + label { + font-weight: 600; + } + + label:after { + margin-left: ($baseline/4); + content: "*"; + } + } + + label, input, textarea { + display: block; + } + + label { + @extend .t-copy-sub1; + @include transition(color, 0.15s, ease-in-out); + margin: 0 0 ($baseline/4) 0; + + &.is-focused { + color: $blue; + } + } + + //this section is borrowed from _account.scss - we should clean up and unify later + input, textarea { + @extend .t-copy-base; + height: 100%; + width: 100%; + padding: ($baseline/2); + + &.long { + width: 100%; + } + + &.short { + width: 25%; + } + + ::-webkit-input-placeholder { + color: $gray-l4; + } + + :-moz-placeholder { + color: $gray-l3; + } + + ::-moz-placeholder { + color: $gray-l3; + } + + :-ms-input-placeholder { + color: $gray-l3; + } + + &:focus { + + .tip { + color: $gray; + } + } + } + + textarea.long { + height: ($baseline*5); + } + + input[type="checkbox"] { + display: inline-block; + margin-right: ($baseline/4); + width: auto; + height: auto; + + & + label { + display: inline-block; + } + } + + .tip { + @extend .t-copy-sub2; + @include transition(color, 0.15s, ease-in-out); + display: block; + margin-top: ($baseline/4); + color: $gray-l3; + } + + &.error { + + label { + color: $red; + } + + input { + border-color: $red; + } + } + } + } + + .actions { + border-top: 1px solid $gray-l1; + padding: ($baseline*0.75) $baseline; + box-shadow: inset 0 1px 2px $shadow; + background: $gray-l6; + + .action-primary { + @include blue-button; + @extend .t-action2; + @include transition(all $tmg-f2 linear 0); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + } + + .action-secondary { + @include grey-button; + @extend .t-action2; + @include transition(all $tmg-f2 linear 0); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + } + } +} + +// ==================== + // forms - grandfathered input.search { padding: 6px 15px 8px 30px; diff --git a/cms/static/sass/elements/_icons.scss b/cms/static/sass/elements/_icons.scss index a75c97ea76..5b5dc0ddfd 100644 --- a/cms/static/sass/elements/_icons.scss +++ b/cms/static/sass/elements/_icons.scss @@ -1,4 +1,4 @@ -// studio - elements - icons +// studio - elements - icons & badges // ==================== .icon { @@ -14,3 +14,42 @@ vertical-align: middle; margin-right: ($baseline/4); } + +// ui - badges +.wrapper-ui-badge { + position: absolute; + top: -1px; + left: ($baseline*1.5); +} + +.ui-badge { + @extend .t-title9; + position: relative; + border-bottom-right-radius: ($baseline/10); + border-bottom-left-radius: ($baseline/10); + padding: ($baseline/4) ($baseline/2) ($baseline/4) ($baseline/2); + font-weight: 600; + text-transform: uppercase; + + * [class^="icon-"] { + margin-right: ($baseline/5); + } + + // OPTION: add this class for a visual hanging display + &.is-hanging { + top: -($baseline/4); + + &:after { + position: absolute; + top: 0; + right: -($baseline/4); + display: block; + height: 0; + width: 0; + border-bottom: ($baseline/4) solid $black-t3; + border-right: ($baseline/4) solid transparent; + content: ""; + opacity: 0.5; + } + } +} diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index 8d2b2d9489..8058673b2b 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -30,7 +30,7 @@ body.course.textbooks { } .textbook { - @extend .window; + @extend .ui-window; position: relative; .view-textbook { diff --git a/cms/static/sass/views/_users.scss b/cms/static/sass/views/_users.scss index ecaa319707..514536ccca 100644 --- a/cms/static/sass/views/_users.scss +++ b/cms/static/sass/views/_users.scss @@ -3,20 +3,56 @@ body.course.users { + // page layout + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } + + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } + + .content-supplementary { + width: flex-grid(3, 12); + } + + // content + .content { + + .introduction { + @extend .t-copy-sub1; + margin: 0 0 ($baseline*2) 0; + } + } + + + // new user form + .add-user { + @extend .new-form; + display: none; + + &.is-shown { + display: block; + } + } + + // new user form (old) .new-user-form { display: none; - padding: 15px 20px; + padding: ($baseline*0.75) $baseline; background-color: $lightBluishGrey2; #result { display: none; float: left; - margin-bottom: 15px; - padding: 3px 15px; + margin-bottom: ($baseline*0.75); + padding: 3px ($baseline*0.75); border-radius: 3px; - background: $error-red; + background: $red; font-size: 14px; - color: #fff; + color: $white; } .form-elements { @@ -25,58 +61,165 @@ body.course.users { label { display: inline-block; - margin-right: 10px; + margin-right: ($baseline/2); } .email-input { width: 350px; padding: 8px 8px 10px; - border-color: $darkGrey; + border-color: $gray-d1; } .add-button { @include blue-button; - padding: 5px 20px 9px; + padding: ($baseline/4) $baseline 9px; } .cancel-button { @include white-button; - padding: 5px 20px 9px; + padding: ($baseline/4) $baseline 9px; } } + // listing of users + .user-list, .user-item, .item-metadata, .item-actions { + @include box-sizing(border-box); + } + .user-list { - border: 1px solid $mediumGrey; - background: #fff; - li { + .user-item { + @extend .ui-window; + @include clearfix(); position: relative; - padding: 20px; - border-bottom: 1px solid $mediumGrey; + width: flex-grid(9, 9); + margin: 0 0 ($baseline/2) 0; + padding: $baseline ($baseline*1.5) $baseline ($baseline*1.5); &:last-child { - border-bottom: none; + margin-bottom: 0; } - span { + .item-metadata, .item-actions { display: inline-block; + vertical-align: middle; } - .user-name { - margin-right: 10px; - font-size: 24px; - font-weight: 300; + // item - flag + .flag-role { + @extend .ui-badge; + color: $white; + + .msg-you { + margin-left: ($baseline/5); + opacity: 0.65; + text-transform: none; + font-weight: 500; + } + + &:after { + border-bottom-color: $black-t1; + } + + &.flag-role-staff { + background: $gray-l2; + } + + &.flag-role-admin { + background: $gray-d1; + } } - .user-email { - font-size: 14px; - font-style: italic; - color: $mediumGrey; + // item - metadata + .item-metadata { + width: flex-grid(5, 9); + margin-right: flex-gutter(); + + .user-username, .user-email { + display: inline-block; + vertical-align: middle; + } + + .user-username { + @extend .t-title4; + @include transition(color $tmg-f2 ease-in-out 0s); + margin: 0 ($baseline/2) ($baseline/10) 0; + color: $gray-d4; + font-weight: 600; + } + + .user-email { + @extend .t-title6; + } } + // item - actions .item-actions { - top: 24px; + width: flex-grid(4, 9); + position: static; // nasty reset needed due to base.scss + text-align: right; + + .action { + display: inline-block; + vertical-align: middle; + } + + .action-role { + margin-right: ($baseline/2); + } + + .action-delete { + + } + + .delete { + @extend .ui-btn-non; + } + + // nasty reset needed due to base.scss + .delete-button { + margin-right: 0; + float: none; + color: inherit; + } + + // admin role controls + .toggle-admin-role { + + &.add-admin-role { + @include blue-button; + @extend .t-action2; + @include transition(all .15s); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + } + + &.remove-admin-role { + @include grey-button; + @extend .t-action2; + @include transition(all .15s); + display: inline-block; + padding: ($baseline/5) $baseline; + font-weight: 600; + } + } + } + + // STATE: hover + &:hover { + + .user-username { + } + + .user-email { + + } + + .item-actions { + + } } } } -} \ No newline at end of file +} diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 41738b1e6d..fd2236a966 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -3,7 +3,7 @@ <%! from auth.authz import is_user_in_course_group_role %> <%inherit file="base.html" /> <%block name="title">${_("Course Team Settings")} -<%block name="bodyclass">is-signedin course users settings team +<%block name="bodyclass">is-signedin course users team <%block name="content"> @@ -27,54 +27,122 @@
      -
      -
      - -
      -

      ${_("The following list of users have been designated as course staff. This means that these users will have permissions to modify course content. You may add additional course staff below, if you are the course instructor. Please note that they must have already registered and verified their account.")}

      +
      +
      +
      +

      Managing Your Course Team

      +
      +

      ${_("[Introduction Message - Mark to Provide Copy.] Maecenas faucibus mollis interdum. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla vitae elit libero, a pharetra augue.")}

      +
      -
      +
      %if allow_actions: -
      -
      -
      - - - + +
      +

      ${_("Add a User to Your Course's Team")}

      + +
      + +
      + +
      + ${_("Textbook information")} +
      + + + ${_("Please provide the email address of the course staff member you'd like to add")} +
      +
      +
      +
      + +
      %endif -
      -
        - % for user in staff: -
      1. - ${user.username} - ${user.email} - % if allow_actions: -
        - <% is_instuctor = is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False) %> + +
          + % for user in staff: +
        1. + + <% is_instuctor = is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False) %> + % if is_instuctor and len(instructors) == 1: + + + ${_("Current Role:")} + + ${_("Admin")} + % if request.user.id == user.id: + ${_("You!")} + % endif + + + + % else: + + + ${_("Current Role:")} + + ${_("Staff")} + % if request.user.id == user.id: + ${_("You!")} + % endif + + + + % endif + + + + % if allow_actions: +
        +
      2. + % if request.user.id != user.id: ## can't remove yourself +
      3. + ${_("Delete the user,")} ${user.username} +
      4. % endif - - % endfor -
      -
      + + % endif + +
    3. + % endfor +
    -
    + + + @@ -88,10 +156,10 @@ ))}" $(document).ready(function() { - var $newUserForm = $('.new-user-form'); + var $newUserForm = $('#add-user-form'); $newUserForm.bind('submit', function(e) { e.preventDefault(); - var url = tplUserURL.replace("@@EMAIL@@", $('#email').val().trim()) + var url = tplUserURL.replace("@@EMAIL@@", $('#user-email-input').val().trim()) $.ajax({ url: url, type: 'POST', @@ -106,23 +174,25 @@ notifyOnError: false, error: function(jqXHR, textStatus, errorThrown) { data = JSON.parse(jqXHR.responseText); - $('#result').show().empty().append(data.error); + $('#add-user-error').toggleClass('is-shown').empty().append(data.error); } }); }); - var $cancelButton = $newUserForm.find('.cancel-button'); + var $cancelButton = $newUserForm.find('.action-cancel'); $cancelButton.bind('click', function(e) { e.preventDefault(); - $newUserForm.slideUp(150); - $('#result').hide(); - $('#email').val(''); + $('.new-user-button').removeClass('is-disabled'); + $newUserForm.toggleClass('is-shown'); + $('#add-user-error').removeClass('is-shown'); + $('#user-email-input').val(''); }); $('.new-user-button').bind('click', function(e) { e.preventDefault(); - $newUserForm.slideDown(150); - $newUserForm.find('.email-input').focus(); + $(this).addClass('is-disabled'); + $newUserForm.toggleClass('is-shown'); + $newUserForm.find('#user-email-input').focus(); }); $('body').bind('keyup', function(e) { From 91f192f6b5a4c18c5c317b788b0d95e0cff58461 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 25 Jul 2013 13:29:33 -0400 Subject: [PATCH 177/556] Added error prompts for the course team page --- cms/templates/manage_users.html | 75 +++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index fd2236a966..76d0fa79d9 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -42,10 +42,6 @@

    ${_("Add a User to Your Course's Team")}

    -
    - -
    -
    ${_("Textbook information")}
    @@ -173,8 +169,26 @@ }, notifyOnError: false, error: function(jqXHR, textStatus, errorThrown) { - data = JSON.parse(jqXHR.responseText); - $('#add-user-error').toggleClass('is-shown').empty().append(data.error); + var message; + try { + message = JSON.parse(jqXHR.responseText).error || "Unknown"; + } catch (e) { + message = "Unknown"; + } + var prompt = new CMS.Views.Prompt.Error({ + title: gettext("Error adding user"), + message: message, + actions: { + primary: { + text: gettext("OK"), + click: function(view) { + view.hide(); + $("#user-email-input").focus() + } + } + } + }) + prompt.show(); } }); }); @@ -184,7 +198,6 @@ e.preventDefault(); $('.new-user-button').removeClass('is-disabled'); $newUserForm.toggleClass('is-shown'); - $('#add-user-error').removeClass('is-shown'); $('#user-email-input').val(''); }); @@ -208,8 +221,30 @@ type: 'DELETE', dataType: 'json', contentType: 'application/json', - complete: function() { + success: function(data) { location.reload(); + }, + notifyOnError: false, + error: function(jqXHR, textStatus, errorThrown) { + var message; + try { + message = JSON.parse(jqXHR.responseText).error || "Unknown"; + } catch (e) { + message = "Unknown"; + } + var prompt = new CMS.Views.Prompt.Error({ + title: gettext("Error removing user"), + message: message, + actions: { + primary: { + text: gettext("OK"), + click: function(view) { + view.hide(); + } + } + } + }) + prompt.show(); } }); }); @@ -231,8 +266,30 @@ data: JSON.stringify({ role: role }), - complete: function() { + success: function(data) { location.reload(); + }, + notifyOnError: false, + error: function(jqXHR, textStatus, errorThrown) { + var message; + try { + message = JSON.parse(jqXHR.responseText).error || "Unknown"; + } catch (e) { + message = "Unknown"; + } + var prompt = new CMS.Views.Prompt.Error({ + title: gettext("Error changing user"), + message: message, + actions: { + primary: { + text: gettext("OK"), + click: function(view) { + view.hide(); + } + } + } + }) + prompt.show(); } }) }) From 5738e4f79d5567fc0131f45a3bb4662de029b15a Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 25 Jul 2013 14:47:23 -0400 Subject: [PATCH 178/556] Pull correct URL when changing user permissions for course team --- cms/templates/manage_users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 76d0fa79d9..4cbfff2f51 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -257,7 +257,7 @@ } else { role = 'staff'; } - var url = $(this).closest("li").data("url"); + var url = $(this).closest("li[data-url]").data("url"); $.ajax({ url: url, type: 'POST', From ecf855eba7c377db14975e661dd192052cc62f36 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 25 Jul 2013 14:50:28 -0400 Subject: [PATCH 179/556] Fixup translations --- cms/templates/manage_users.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 4cbfff2f51..6dc1561c9f 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -98,7 +98,7 @@

    ${user.username} - ${user.email} + ${user.email}

    @@ -114,7 +114,7 @@
  • % if request.user.id != user.id: ## can't remove yourself
  • - ${_("Delete the user,")} ${user.username} + ${_("Delete the user, {username}").format(username=user.username)}
  • % endif From 41832744d78b323bd8b6d9cc5928ca2a4242a8b6 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 25 Jul 2013 14:50:55 -0400 Subject: [PATCH 180/556] Correct course team admin badging logic --- cms/templates/manage_users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 6dc1561c9f..e43ceaea59 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -68,7 +68,7 @@ ))}"> <% is_instuctor = is_user_in_course_group_role(user, context_course.location, 'instructor', check_staff=False) %> - % if is_instuctor and len(instructors) == 1: + % if is_instuctor: ${_("Current Role:")} From e5ef5ef1a0f54743e0a3d1019b014d95cfbb783d Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 25 Jul 2013 14:54:21 -0400 Subject: [PATCH 181/556] Show disabled trash icon instead of not showing it at all --- cms/templates/manage_users.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index e43ceaea59..7ab8e912f7 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -112,11 +112,9 @@ ${_("Remove Admin Access") if is_instuctor else _("Add Admin Access")} % endif - % if request.user.id != user.id: ## can't remove yourself -
  • +
  • ${_("Delete the user, {username}").format(username=user.username)}
  • - % endif % endif From deced24b3284499ce099deadf8decf0a3b52f91c Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 25 Jul 2013 16:41:53 -0400 Subject: [PATCH 182/556] Studio: refactored form-based Sass and revised markup/copy for course team admin mgmt --- cms/static/sass/elements/_forms.scss | 147 +++++++++++++++++++-------- cms/static/sass/views/_users.scss | 80 +++++---------- cms/templates/manage_users.html | 59 ++++++----- 3 files changed, 160 insertions(+), 126 deletions(-) diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 3d11db02c3..55048b49dc 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -93,36 +93,47 @@ form { } } -// ==================== -// forms - archetype - add a new X form -.new-form { - @extend .ui-window; - @include box-sizing(border-box); - position: relative; - width: 100%; - margin-bottom: ($baseline*2); - border-radius: 2px; - background: $white; +// ELEM: form wrapper +.wrapper-create-element { + height: 0; + margin-bottom: $baseline; + opacity: 0.0; + pointer-events: none; + overflow: hidden; - .wrapper-form { - padding: $baseline ($baseline*1.5); + &.animate { + @include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s); } + &.is-shown { + height: auto; // define a specific height for the animating version of this UI to work properly + opacity: 1.0; + pointer-events: auto; + } +} + +// ELEM: form +// form styling for creating a new content item (course, user, textbook) +form[class^="create-"] { + @extend .ui-window; + @include box-sizing(border-box); + border-radius: 2px; + width: 100%; + background: $white; + .title { @extend .t-title4; - font-weight: 700; - margin-bottom: $baseline; - border-bottom: 1px solid $gray-l4; - padding-bottom: ($baseline/2); + font-weight: 600; + padding: $baseline ($baseline*1.5) 0 ($baseline*1.5); } fieldset { - margin-bottom: $baseline; + padding: $baseline ($baseline*1.5); } - // form elements - need to make this more universal - .form-fields { + + .list-input { @extend .cont-no-list; .field { @@ -150,7 +161,7 @@ form { label { @extend .t-copy-sub1; - @include transition(color, 0.15s, ease-in-out); + @include transition(color $tmg-f3 ease-in-out 0s); margin: 0 0 ($baseline/4) 0; &.is-focused { @@ -158,8 +169,9 @@ form { } } - //this section is borrowed from _account.scss - we should clean up and unify later + input, textarea { + @include transition(all $tmg-f2 ease-in-out 0s); @extend .t-copy-base; height: 100%; width: 100%; @@ -173,23 +185,8 @@ form { width: 25%; } - ::-webkit-input-placeholder { - color: $gray-l4; - } - - :-moz-placeholder { - color: $gray-l3; - } - - ::-moz-placeholder { - color: $gray-l3; - } - - :-ms-input-placeholder { - color: $gray-l3; - } - &:focus { + + .tip { color: $gray; } @@ -219,45 +216,111 @@ form { color: $gray-l3; } - &.error { + .tip-error { + display: none; + float: none; + } + &.error { label { color: $red; } + .tip-error { + @extend .anim-fadeIn; + display: block; + color: $red; + } + input { border-color: $red; } } } + + .field-inline { + + input, textarea, select { + width: 62%; + display: inline-block; + } + + .tip-stacked { + display: inline-block; + float: right; + width: 35%; + margin-top: 0; + } + + &.error { + .tip-error { + } + } + + } + + .field-group { + @include clearfix(); + margin: 0 0 ($baseline/2) 0; + + .field { + display: block; + width: 47%; + border-bottom: none; + margin: 0 ($baseline*0.75) 0 0; + padding: ($baseline/4) 0 0 0; + float: left; + position: relative; + + &:nth-child(odd) { + float: left; + } + + &:nth-child(even) { + float: right; + margin-right: 0; + } + + input, textarea { + width: 100%; + } + } + } } .actions { - border-top: 1px solid $gray-l1; - padding: ($baseline*0.75) $baseline; box-shadow: inset 0 1px 2px $shadow; + margin-top: ($baseline*0.75); + border-top: 1px solid $gray-l1; + padding: ($baseline*0.75) ($baseline*1.5); background: $gray-l6; .action-primary { @include blue-button; @extend .t-action2; - @include transition(all $tmg-f2 linear 0); + @include transition(all .15s); display: inline-block; padding: ($baseline/5) $baseline; font-weight: 600; + text-transform: uppercase; } .action-secondary { @include grey-button; @extend .t-action2; - @include transition(all $tmg-f2 linear 0); + @include transition(all .15s); display: inline-block; padding: ($baseline/5) $baseline; font-weight: 600; + text-transform: uppercase; } } } + + + + // ==================== // forms - grandfathered diff --git a/cms/static/sass/views/_users.scss b/cms/static/sass/views/_users.scss index 514536ccca..633385309b 100644 --- a/cms/static/sass/views/_users.scss +++ b/cms/static/sass/views/_users.scss @@ -3,7 +3,7 @@ body.course.users { - // page layout + // LAYOUT: page .content-primary, .content-supplementary { @include box-sizing(border-box); float: left; @@ -18,7 +18,7 @@ body.course.users { width: flex-grid(3, 12); } - // content + // ELEM: content .content { .introduction { @@ -28,60 +28,15 @@ body.course.users { } - // new user form - .add-user { - @extend .new-form; - display: none; + // ELEM: new user form + .wrapper-create-user { &.is-shown { - display: block; + height: ($baseline*15); } } - // new user form (old) - .new-user-form { - display: none; - padding: ($baseline*0.75) $baseline; - background-color: $lightBluishGrey2; - - #result { - display: none; - float: left; - margin-bottom: ($baseline*0.75); - padding: 3px ($baseline*0.75); - border-radius: 3px; - background: $red; - font-size: 14px; - color: $white; - } - - .form-elements { - clear: both; - } - - label { - display: inline-block; - margin-right: ($baseline/2); - } - - .email-input { - width: 350px; - padding: 8px 8px 10px; - border-color: $gray-d1; - } - - .add-button { - @include blue-button; - padding: ($baseline/4) $baseline 9px; - } - - .cancel-button { - @include white-button; - padding: ($baseline/4) $baseline 9px; - } - } - - // listing of users + // ELEM: listing of users .user-list, .user-item, .item-metadata, .item-actions { @include box-sizing(border-box); } @@ -94,7 +49,7 @@ body.course.users { position: relative; width: flex-grid(9, 9); margin: 0 0 ($baseline/2) 0; - padding: $baseline ($baseline*1.5) $baseline ($baseline*1.5); + padding: ($baseline*1.25) ($baseline*1.5) $baseline ($baseline*1.5); &:last-child { margin-bottom: 0; @@ -105,7 +60,7 @@ body.course.users { vertical-align: middle; } - // item - flag + // ELEM: item - flag .flag-role { @extend .ui-badge; color: $white; @@ -130,7 +85,7 @@ body.course.users { } } - // item - metadata + // ELEM: item - metadata .item-metadata { width: flex-grid(5, 9); margin-right: flex-gutter(); @@ -153,7 +108,7 @@ body.course.users { } } - // item - actions + // ELEM: item - actions .item-actions { width: flex-grid(4, 9); position: static; // nasty reset needed due to base.scss @@ -170,20 +125,26 @@ body.course.users { .action-delete { + // STATE: disabled + &.is-disabled { + opacity: 0.0; + visibility: hidden; + pointer-events: none; + } } .delete { @extend .ui-btn-non; } - // nasty reset needed due to base.scss + // HACK: nasty reset needed due to base.scss .delete-button { margin-right: 0; float: none; color: inherit; } - // admin role controls + // ELEM: admin role controls .toggle-admin-role { &.add-admin-role { @@ -204,6 +165,11 @@ body.course.users { font-weight: 600; } } + + .notoggleforyou { + @extend .t-copy-sub2; + color: $gray-l2; + } } // STATE: hover diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 7ab8e912f7..48b68a2731 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -19,7 +19,7 @@ @@ -38,24 +38,28 @@
    %if allow_actions: -
    -
    -

    ${_("Add a User to Your Course's Team")}

    +
    + +
    +

    ${_("Add a User to Your Course's Team")}

    -
    - ${_("Textbook information")} -
    - - - ${_("Please provide the email address of the course staff member you'd like to add")} -
    -
    -
    -
    - - -
    - +
    + ${_("Textbook information")} +
      +
    1. + + + ${_("Please provide the email address of the course staff member you'd like to add")} +
    2. +
    +
    +
    +
    + + +
    + +
    %endif
      @@ -107,7 +111,7 @@
    From 6399eda11898ddd93bae27f897e944a72d8e2c30 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 29 Jul 2013 14:49:22 -0400 Subject: [PATCH 260/556] be sure to mark javascript local variables with var --- cms/static/js/base.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index b10d4b31bd..6c15f6ed2e 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -644,14 +644,14 @@ function saveNewCourse(e) { var number = $newCourse.find('.new-course-number').val(); var run = $newCourse.find('.new-course-run').val(); - required_field_text = gettext('Required field'); + var required_field_text = gettext('Required field'); - display_name_errMsg = (display_name === '') ? required_field_text : null; - org_errMsg = (org === '') ? required_field_text : null; - number_errMsg = (number === '') ? required_field_text : null; - run_errMsg = (run === '') ? required_field_text : null; + var display_name_errMsg = (display_name === '') ? required_field_text : null; + var org_errMsg = (org === '') ? required_field_text : null; + var number_errMsg = (number === '') ? required_field_text : null; + var run_errMsg = (run === '') ? required_field_text : null; - bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg); + var bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg); // check for suitable encoding if (!bInErr) { @@ -664,10 +664,10 @@ function saveNewCourse(e) { if (encodeURIComponent(run) != run) run_errMsg = encoding_errMsg; - bInErr = (display_name_errMsg || org_errMsg || number_errMsg || run_errMsg); + bInErr = (org_errMsg || number_errMsg || run_errMsg); } - header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null; + var header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null; setNewCourseErrMsgs(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg); @@ -691,8 +691,8 @@ function saveNewCourse(e) { if (data.id !== undefined) { window.location = '/' + data.id.replace(/.*:\/\//, ''); } else if (data.ErrMsg !== undefined) { - orgErrMsg = (data.OrgErrMsg !== undefined) ? data.OrgErrMsg : null; - courseErrMsg = (data.CourseErrMsg !== undefined) ? data.CourseErrMsg : null; + var orgErrMsg = (data.OrgErrMsg !== undefined) ? data.OrgErrMsg : null; + var courseErrMsg = (data.CourseErrMsg !== undefined) ? data.CourseErrMsg : null; setNewCourseErrMsgs(data.ErrMsg, null, orgErrMsg, courseErrMsg, null); } } From 279896e484f3f8035472531689b89bcf583ee01f Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 29 Jul 2013 14:57:14 -0400 Subject: [PATCH 261/556] put new unique functions inside the primary function --- cms/static/js/base.js | 50 +++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 6c15f6ed2e..8d09885231 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -610,31 +610,6 @@ function addNewCourse(e) { }, checkForCancel); } -function setNewCourseFieldInErr(el, msg) { - el.children('.tip-error').remove(); - if (msg !== null && msg !== '') { - el.addClass('error'); - el.append('' + msg + ''); - } else { - el.removeClass('error'); - } -} - -function setNewCourseErrMsgs(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) { - if (header_err_msg) { - $('.wrap-error').addClass('is-shown'); - $('#course_creation_error').html('

    ' + header_err_msg + '

    '); - } else { - $('.wrap-error').removeClass('is-shown'); - $('#course_creation_error').html(''); - } - - setNewCourseFieldInErr($('#field-course-name'), display_name_errMsg); - setNewCourseFieldInErr($('#field-organization'), org_errMsg); - setNewCourseFieldInErr($('#field-course-number'), number_errMsg); - setNewCourseFieldInErr($('#field-course-run'), run_errMsg); -} - function saveNewCourse(e) { e.preventDefault(); @@ -669,6 +644,31 @@ function saveNewCourse(e) { var header_err_msg = (bInErr) ? gettext('Please correct the fields below.') : null; + var setNewCourseErrMsgs = function(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) { + if (header_err_msg) { + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

    ' + header_err_msg + '

    '); + } else { + $('.wrap-error').removeClass('is-shown'); + $('#course_creation_error').html(''); + } + + var setNewCourseFieldInErr = function(el, msg) { + el.children('.tip-error').remove(); + if (msg !== null && msg !== '') { + el.addClass('error'); + el.append('' + msg + ''); + } else { + el.removeClass('error'); + } + }; + + setNewCourseFieldInErr($('#field-course-name'), display_name_errMsg); + setNewCourseFieldInErr($('#field-organization'), org_errMsg); + setNewCourseFieldInErr($('#field-course-number'), number_errMsg); + setNewCourseFieldInErr($('#field-course-run'), run_errMsg); + }; + setNewCourseErrMsgs(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg); if (bInErr) From 2ce2f08664e540f1ced0b2e2b9ec601a2a6382bd Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Mon, 29 Jul 2013 15:07:55 -0400 Subject: [PATCH 262/556] fix for extra long error on course creation --- cms/static/sass/views/_dashboard.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index 81a3f0b369..a79177cc31 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -425,7 +425,7 @@ body.dashboard { } .wrap-error.is-shown { - height: 45px; + height: 65px; opacity: 1; } From f50d304166fa7226636e4adb25a4d0afd99a8331 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 29 Jul 2013 15:11:57 -0400 Subject: [PATCH 263/556] missed a local variable --- cms/static/js/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 8d09885231..77432eff12 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -630,7 +630,7 @@ function saveNewCourse(e) { // check for suitable encoding if (!bInErr) { - encoding_errMsg = gettext('Please do not use any spaces or special characters in this field.'); + var encoding_errMsg = gettext('Please do not use any spaces or special characters in this field.'); if (encodeURIComponent(org) != org) org_errMsg = encoding_errMsg; From d321406cc32eced39d15f83e31e6d5b07450aacc Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 29 Jul 2013 15:20:16 -0400 Subject: [PATCH 264/556] fix indent --- cms/static/js/base.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 77432eff12..3e79b1a1ad 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -682,12 +682,12 @@ function saveNewCourse(e) { }); $.post('/create_new_course', { - 'org': org, - 'number': number, - 'display_name': display_name, - 'run': run - }, - function(data) { + 'org': org, + 'number': number, + 'display_name': display_name, + 'run': run + }, + function(data) { if (data.id !== undefined) { window.location = '/' + data.id.replace(/.*:\/\//, ''); } else if (data.ErrMsg !== undefined) { From d005323acedd7657784b61ccd925d3fac7d1d4b0 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 29 Jul 2013 15:24:27 -0400 Subject: [PATCH 265/556] update error message to be more specific to what the user needs to fix --- cms/djangoapps/contentstore/tests/test_contentstore.py | 2 +- cms/djangoapps/contentstore/views/course.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 18fbf6fdf1..a9fe7cbe02 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -990,7 +990,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_duplicate_course(self): """Test new course creation - error path""" self.client.post(reverse('create_new_course'), self.course_data) - self.assert_course_creation_failed('There is already a course defined with the same organization, course number, and course run. Please change at least one field to be unique.') + self.assert_course_creation_failed('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.') def assert_course_creation_failed(self, error_message): """ diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 5d367fbe8f..409d790a32 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -120,7 +120,7 @@ def create_new_course(request): if existing_course is not None: return JsonResponse( { - 'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change at least one field to be unique.'), + 'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.'), 'OrgErrMsg': _('Either of organization or course number must be unique.'), 'CourseErrMsg': _('Either of organization or course number must be unique.'), } From ec9ea28e9283ea294691c7c6f37e915cc78daaca Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 29 Jul 2013 16:23:23 -0400 Subject: [PATCH 266/556] Studio: abstracting out shared creation form action properties --- cms/static/sass/elements/_forms.scss | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index 29b7f0fa20..c78e2f3692 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -294,32 +294,26 @@ form[class^="create-"] { padding: ($baseline*0.75) ($baseline*1.5); background: $gray-l6; - .action-primary { - @include blue-button; - @extend .t-action2; - @include transition(all .15s); + .action { + @include transition(all $tmg-f2 linear 0s); display: inline-block; padding: ($baseline/5) $baseline; font-weight: 600; text-transform: uppercase; } + .action-primary { + @include blue-button; + @extend .t-action2; + } + .action-secondary { @include grey-button; @extend .t-action2; - @include transition(all .15s); - display: inline-block; - padding: ($baseline/5) $baseline; - font-weight: 600; - text-transform: uppercase; } } } - - - - // ==================== // forms - grandfathered From beda411e289ba95d3dbe90b291c508a341fd9201 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 30 Jul 2013 11:59:05 -0400 Subject: [PATCH 267/556] improve error message regarding uniqueness --- cms/djangoapps/contentstore/views/course.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 409d790a32..e68210dea4 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -121,8 +121,8 @@ def create_new_course(request): return JsonResponse( { 'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.'), - 'OrgErrMsg': _('Either of organization or course number must be unique.'), - 'CourseErrMsg': _('Either of organization or course number must be unique.'), + 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'), + 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'), } ) @@ -132,8 +132,8 @@ def create_new_course(request): return JsonResponse( { 'ErrMsg': _('There is already a course defined with the same organization and course number. Please change at least one field to be unique.'), - 'OrgErrMsg': _('Either of organization or course number must be unique.'), - 'CourseErrMsg': _('Either of organization or course number must be unique.'), + 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'), + 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'), } ) From 9725cff71e3df57e1cfef466b1046dda0038bd1e Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Tue, 30 Jul 2013 17:31:48 -0400 Subject: [PATCH 268/556] Remove get_preview_module. And replace calls with calls to load_preview_module. --- cms/djangoapps/contentstore/views/preview.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 35af3e9ac3..d3a5dd1c8d 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -105,7 +105,7 @@ def preview_module_system(request, preview_id, descriptor): # TODO (cpennington): Do we want to track how instructors are using the preview problems? track_function=lambda event_type, event: None, filestore=descriptor.system.resources_fs, - get_module=partial(get_preview_module, request, preview_id), + get_module=partial(load_preview_module, request, preview_id), render_template=render_from_lms, debug=True, replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_namespace=descriptor.location), @@ -115,28 +115,13 @@ def preview_module_system(request, preview_id, descriptor): ) -def get_preview_module(request, preview_id, descriptor): - """ - Returns a preview XModule at the specified location. The preview_data is chosen arbitrarily - from the set of preview data for the descriptor specified by Location - - request: The active django request - preview_id (str): An identifier specifying which preview this module is used for - location: A Location - """ - - return load_preview_module(request, preview_id, descriptor) - - def load_preview_module(request, preview_id, descriptor): """ - Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state + Return a preview XModule instantiated from the supplied descriptor. request: The active django request preview_id (str): An identifier specifying which preview this module is used for descriptor: An XModuleDescriptor - instance_state: An instance state string - shared_state: A shared state string """ system = preview_module_system(request, preview_id, descriptor) try: From 5451bb642c4974bbd7fc3e72630a7fc05c7c858c Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Tue, 30 Jul 2013 17:43:43 -0400 Subject: [PATCH 269/556] Code cleanup. From PR review. --- docs/shared/conf.py | 10 +++++++++- rakelib/docs.rake | 1 - 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/shared/conf.py b/docs/shared/conf.py index e04495d11b..5f0a2abd89 100644 --- a/docs/shared/conf.py +++ b/docs/shared/conf.py @@ -25,7 +25,15 @@ import sys, os BASEDIR = os.path.dirname(os.path.abspath(__file__)) -add_base = lambda l: map(lambda x: os.path.join(BASEDIR, x), l) +def add_base(paths): + """ + Returns a list of paths relative to BASEDIR. + + paths: a list of paths + """ + + return [os.path.join(BASEDIR, x) for x in paths] + # If extensions (or modules to document with autodoc) are in another directory, diff --git a/rakelib/docs.rake b/rakelib/docs.rake index 3d3224cd74..ce6c65f7fb 100644 --- a/rakelib/docs.rake +++ b/rakelib/docs.rake @@ -25,7 +25,6 @@ end desc "Show docs in browser (mac and ubuntu)." task :showdocs, [:options] do |t, args| - path = "docs" if args.options == 'dev' path = "docs/developer" elsif args.options == 'author' From 16187462b3316308c0fc4c84ecbd078d311c1729 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 30 Jul 2013 18:21:07 -0400 Subject: [PATCH 270/556] Studio: syncs up visual and semantic standards b/t create course and user forms --- cms/static/js/base.js | 21 +++--- cms/static/sass/views/_dashboard.scss | 34 ++++++---- cms/templates/index.html | 98 ++++++++++++++------------- cms/templates/manage_users.html | 2 + 4 files changed, 84 insertions(+), 71 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 3e79b1a1ad..de0fd955dc 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -597,11 +597,9 @@ function cancelNewSection(e) { function addNewCourse(e) { e.preventDefault(); - $('.new-course-button').addClass('disabled'); - $(e.target).addClass('disabled'); - var $newCourse = $($('#new-course-template').html()); + $('.new-course-button').addClass('is-disabled'); + var $newCourse = $('.wrapper-create-course').addClass('is-shown'); var $cancelButton = $newCourse.find('.new-course-cancel'); - $('.courses').prepend($newCourse); $newCourse.find('.new-course-name').focus().select(); $newCourse.find('form').bind('submit', saveNewCourse); $cancelButton.bind('click', cancelNewCourse); @@ -613,11 +611,11 @@ function addNewCourse(e) { function saveNewCourse(e) { e.preventDefault(); - var $newCourse = $(this).closest('.new-course'); - var display_name = $newCourse.find('.new-course-name').val(); - var org = $newCourse.find('.new-course-org').val(); - var number = $newCourse.find('.new-course-number').val(); - var run = $newCourse.find('.new-course-run').val(); + var $newCourseForm = $(this).closest('#create-course-form'); + var display_name = $newCourseForm.find('.new-course-name').val(); + var org = $newCourseForm.find('.new-course-org').val(); + var number = $newCourseForm.find('.new-course-number').val(); + var run = $newCourseForm.find('.new-course-run').val(); var required_field_text = gettext('Required field'); @@ -646,6 +644,7 @@ function saveNewCourse(e) { var setNewCourseErrMsgs = function(header_err_msg, display_name_errMsg, org_errMsg, number_errMsg, run_errMsg) { if (header_err_msg) { + $('.wrapper-create-course').addClass('has-errors'); $('.wrap-error').addClass('is-shown'); $('#course_creation_error').html('

    ' + header_err_msg + '

    '); } else { @@ -701,8 +700,8 @@ function saveNewCourse(e) { function cancelNewCourse(e) { e.preventDefault(); - $('.new-course-button').removeClass('disabled'); - $(this).parents('section.new-course').remove(); + $('.new-course-button').removeClass('is-disabled'); + $('.wrapper-create-course').removeClass('is-shown'); } function addNewSubsection(e) { diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index a79177cc31..ab3ad6f810 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -358,20 +358,30 @@ body.dashboard { } } - .new-course { - @include clearfix(); - padding: 0; - margin-top: $baseline; - border-radius: 3px; - border: 1px solid $gray; - background: $white; - box-shadow: 0 1px 2px rgba(0, 0, 0, .1); - .title { - @extend .t-title4; - font-weight: 700; - margin-bottom: $baseline; + // ELEM: new user form + .wrapper-create-course { + + // CASE: when form is animating + &.animate { + + // STATE: shown + &.is-shown { + height: ($baseline*26); + + // STATE: errors + &.has-errors { + height: ($baseline*33); + } + } } + } + + // ==================== + + // course listings + + .create-course { .row { @include clearfix(); diff --git a/cms/templates/index.html b/cms/templates/index.html index 619824ede6..ff6318ea5e 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -38,55 +38,7 @@ <%block name="header_extras"> @@ -133,6 +85,56 @@ %endif +
    + +
    + +
    + +
    +

    ${_("Create a New Course")}

    + +
    + ${_("Required Information to Create a New Course")} + +
      +
    1. + + + ${_("The public display name for your course.")} +
    2. +
    3. + + + ${_("The name of the organization sponsoring the course")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
    4. + +
    5. + + + ${_("The unique number that identifies your course within your organization")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
    6. + +
    7. + + + ${_("The term in which your course will run")} - ${_("Note: No spaces or special characters are allowed. This cannot be changed.")} +
    8. +
    + +
    +
    + +
    + + +
    + + +
    + %if len(courses) > 0:
      diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 6256d25333..0ce0067da3 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -38,6 +38,7 @@
      ${_("New Team Member Information")} +
      1. @@ -47,6 +48,7 @@
    +
    From 50b930a772beace929ddb447577ae0f90d635b66 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 30 Jul 2013 18:21:07 -0400 Subject: [PATCH 271/556] Studio: syncs up visual and semantic standards b/t create course and user forms --- cms/templates/index.html | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cms/templates/index.html b/cms/templates/index.html index ff6318ea5e..5280e2ed96 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -36,12 +36,6 @@ -<%block name="header_extras"> - - - <%block name="content">
    From 61327bc59906f60ce657eddcca586d9bd486741b Mon Sep 17 00:00:00 2001 From: Vasyl Nakvasiuk Date: Wed, 31 Jul 2013 10:43:06 +0300 Subject: [PATCH 272/556] fix python code styles --- common/lib/xmodule/xmodule/gst_module.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/gst_module.py b/common/lib/xmodule/xmodule/gst_module.py index 9605aee2b0..d6516d92ef 100644 --- a/common/lib/xmodule/xmodule/gst_module.py +++ b/common/lib/xmodule/xmodule/gst_module.py @@ -95,12 +95,14 @@ class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule): @property def configuration(self): return stringify_children( - html.fromstring(self.data).xpath('configuration')[0]) + html.fromstring(self.data).xpath('configuration')[0] + ) @property def render(self): return stringify_children( - html.fromstring(self.data).xpath('render')[0]) + html.fromstring(self.data).xpath('render')[0] + ) def get_html(self): """ Renders parameters to template. """ From a48c7cfc269667be4b10158eac67e7d880e81db0 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 31 Jul 2013 08:38:46 -0400 Subject: [PATCH 273/556] Replaced direct call to `browser.is_text_present()` with `world.css_find()` to protect against StaleElementException in Jenkins. --- cms/djangoapps/contentstore/features/signup.feature | 3 +-- cms/djangoapps/contentstore/features/signup.py | 12 ++++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index 01c912deca..c249ad61e8 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -8,8 +8,7 @@ Feature: Sign in When I click the link with the text "Sign Up" And I fill in the registration form And I press the Create My Account button on the registration form - Then I should see be on the studio home page - And I should see the message "complete your sign up we need you to verify your email address" + Then I should see an email verification prompt Scenario: Login with a valid redirect Given I have opened a new course in Studio diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index e9abb55a78..94c6e6f18e 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -22,14 +22,10 @@ def i_press_the_button_on_the_registration_form(step): world.css_click(submit_css) -@step('I should see be on the studio home page$') -def i_should_see_be_on_the_studio_home_page(step): - step.given('I should see the message "My Courses"') - - -@step(u'I should see the message "([^"]*)"$') -def i_should_see_the_message(step, msg): - assert world.browser.is_text_present(msg, 5) +@step('I should see an email verification prompt') +def i_should_see_an_email_verification_prompt(step): + world.css_has_text('h1.page-header', u'My Courses') + world.css_has_text('div.msg h3.title', u'We need to verify your email address') @step(u'I fill in and submit the signin form$') From 99f9894f1cd11bd0e8c47cb2599ae397241fd85b Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 24 Jul 2013 08:51:30 -0400 Subject: [PATCH 274/556] Switch LMS over to using XBlock rendering commands This makes the LMS use an XBlock's student_view, rather than an XModule's get_html to render for display. However, it does not yet use wrap_child to handle instructor debug information or url rewriting. [LMS-219] --- common/lib/xmodule/xmodule/x_module.py | 3 +- lms/djangoapps/courseware/courses.py | 4 +- lms/djangoapps/courseware/tabs.py | 2 +- lms/djangoapps/courseware/tests/__init__.py | 8 +- .../courseware/tests/test_module_render.py | 122 +++++++++++++++++- .../courseware/tests/test_videoalpha_mongo.py | 12 +- .../courseware/tests/test_videoalpha_xml.py | 7 +- .../courseware/tests/test_word_cloud.py | 6 +- lms/djangoapps/courseware/views.py | 2 +- 9 files changed, 136 insertions(+), 30 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 65c9a0e981..d399001a6a 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -12,6 +12,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecif from xblock.core import XBlock, Scope, String, Integer, Float, ModelType from xblock.fragment import Fragment +from xblock.runtime import Runtime from xmodule.modulestore.locator import BlockUsageLocator log = logging.getLogger(__name__) @@ -870,7 +871,7 @@ class XMLParsingSystem(DescriptorSystem): self.policy = policy -class ModuleSystem(object): +class ModuleSystem(Runtime): ''' This is an abstraction such that x_modules can function independent of the courseware (e.g. import into other types of courseware, LMS, diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index ef1b786645..91ec14011e 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -163,7 +163,7 @@ def get_course_about_section(course, section_key): html = '' if about_module is not None: - html = about_module.get_html() + html = about_module.runtime.render(about_module, None, 'student_view').content return html @@ -211,7 +211,7 @@ def get_course_info_section(request, course, section_key): html = '' if info_module is not None: - html = info_module.get_html() + html = info_module.runtime.render(info_module, None, 'student_view').content return html diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 149542c344..7a77898cdd 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -371,6 +371,6 @@ def get_static_tab_contents(request, course, tab): html = '' if tab_module is not None: - html = tab_module.get_html() + html = tab_module.runtime.render(tab_module, None, 'student_view').content return html diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 33c8d12701..3169606a7b 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -77,13 +77,15 @@ class BaseTestXmodule(ModuleStoreTestCase): data=self.DATA ) - system = get_test_system() - system.render_template = lambda template, context: context + self.runtime = get_test_system() + # Allow us to assert that the template was called in the same way from + # different code paths while maintaining the type returned by render_template + self.runtime.render_template = lambda template, context: unicode((template, sorted(context.items()))) model_data = {'location': self.item_descriptor.location} model_data.update(self.MODEL_DATA) self.item_module = self.item_descriptor.module_class( - system, self.item_descriptor, model_data + self.runtime, self.item_descriptor, model_data ) self.item_url = Location(self.item_module.location).url() diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 6b409f677b..25056ba100 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1,7 +1,7 @@ """ Test for lms courseware app, module render unit """ -from mock import MagicMock, patch +from mock import MagicMock, patch, Mock import json from django.http import Http404, HttpResponse @@ -12,8 +12,10 @@ from django.test.client import RequestFactory from django.test.utils import override_settings from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase import courseware.module_render as render -from courseware.tests.tests import LoginEnrollmentTestCase +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_MONGO_MODULESTORE from courseware.model_data import ModelDataCache from modulestore_config import TEST_DATA_XML_MODULESTORE @@ -49,8 +51,10 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase): dispatch=self.dispatch)) def test_get_module(self): - self.assertIsNone(render.get_module('dummyuser', None, - 'invalid location', None, None)) + self.assertEqual( + None, + render.get_module('dummyuser', None, 'invalid location', None, None) + ) def test_module_render_with_jump_to_id(self): """ @@ -230,7 +234,8 @@ class TestTOC(TestCase): 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None, model_data_cache) - assert reduce(lambda x, y: x and (y in actual), expected, True) + for toc_section in expected: + self.assertIn(toc_section, actual) def test_toc_toy_from_section(self): chapter = 'Overview' @@ -257,4 +262,109 @@ class TestTOC(TestCase): 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section, model_data_cache) - assert reduce(lambda x, y: x and (y in actual), expected, True) + for toc_section in expected: + self.assertIn(toc_section, actual) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestHtmlModifiers(ModuleStoreTestCase): + """ + Tests to verify that standard modifications to the output of XModule/XBlock + student_view are taking place + """ + def setUp(self): + self.user = UserFactory.create() + self.request = RequestFactory().get('/') + self.request.user = self.user + self.request.session = {} + self.course = CourseFactory.create() + self.content_string = '

    This is the content

    ' + self.rewrite_link = 'Test rewrite' + self.course_link = 'Test course rewrite' + self.descriptor = ItemFactory.create( + category='html', + data=self.content_string + self.rewrite_link + self.course_link + ) + self.location = self.descriptor.location + self.model_data_cache = ModelDataCache.cache_for_descriptor_descendents( + self.course.id, + self.user, + self.descriptor + ) + + def test_xmodule_display_wrapper_enabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.model_data_cache, + self.course.id, + wrap_xmodule_display=True, + ) + result_fragment = module.runtime.render(module, None, 'student_view') + + self.assertIn('section class="xmodule_display xmodule_HtmlModule"', result_fragment.content) + + def test_xmodule_display_wrapper_disabled(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.model_data_cache, + self.course.id, + wrap_xmodule_display=False, + ) + result_fragment = module.runtime.render(module, None, 'student_view') + + self.assertNotIn('section class="xmodule_display xmodule_HtmlModule"', result_fragment.content) + + def test_static_link_rewrite(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.model_data_cache, + self.course.id, + ) + result_fragment = module.runtime.render(module, None, 'student_view') + + self.assertIn( + '/c4x/{org}/{course}/asset/foo_content'.format( + org=self.course.location.org, + course=self.course.location.course, + ), + result_fragment.content + ) + + def test_course_link_rewrite(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.model_data_cache, + self.course.id, + ) + result_fragment = module.runtime.render(module, None, 'student_view') + + self.assertIn( + '/courses/{course_id}/bar/content'.format( + course_id=self.course.id + ), + result_fragment.content + ) + + @patch('courseware.module_render.has_access', Mock(return_value=True)) + def test_histogram(self): + module = render.get_module( + self.user, + self.request, + self.location, + self.model_data_cache, + self.course.id, + ) + result_fragment = module.runtime.render(module, None, 'student_view') + + self.assertIn( + 'Staff Debug', + result_fragment.content + ) diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py index d5afb1a78c..30cf556b9f 100644 --- a/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py +++ b/lms/djangoapps/courseware/tests/test_videoalpha_mongo.py @@ -34,9 +34,7 @@ class TestVideo(BaseTestXmodule): def test_videoalpha_constructor(self): """Make sure that all parameters extracted correclty from xml""" - # `get_html` return only context, cause we - # overwrite `system.render_template` - context = self.item_module.get_html() + fragment = self.runtime.render(self.item_module, None, 'student_view') expected_context = { 'data_dir': getattr(self, 'data_dir', None), 'caption_asset_path': '/c4x/MITx/999/asset/subs_', @@ -51,7 +49,7 @@ class TestVideo(BaseTestXmodule): 'youtube_streams': self.item_module.youtube_streams, 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) } - self.assertDictEqual(context, expected_context) + self.assertEqual(fragment.content, self.runtime.render_template('videoalpha.html', expected_context)) class TestVideoNonYouTube(TestVideo): @@ -78,9 +76,7 @@ class TestVideoNonYouTube(TestVideo): the template generates an empty string for the YouTube streams. """ - # `get_html` return only context, cause we - # overwrite `system.render_template` - context = self.item_module.get_html() + fragment = self.runtime.render(self.item_module, None, 'student_view') expected_context = { 'data_dir': getattr(self, 'data_dir', None), 'caption_asset_path': '/c4x/MITx/999/asset/subs_', @@ -95,4 +91,4 @@ class TestVideoNonYouTube(TestVideo): 'youtube_streams': '', 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) } - self.assertDictEqual(context, expected_context) + self.assertEqual(fragment.content, self.runtime.render_template('videoalpha.html', expected_context)) diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_xml.py b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py index a14fc6cac6..ae49be86dc 100644 --- a/lms/djangoapps/courseware/tests/test_videoalpha_xml.py +++ b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py @@ -104,10 +104,9 @@ class VideoAlphaModuleUnitTest(unittest.TestCase): def test_videoalpha_constructor(self): """Make sure that all parameters extracted correclty from xml""" module = VideoAlphaFactory.create() + module.runtime.render_template = lambda template, context: unicode((template, sorted(context.items()))) - # `get_html` return only context, cause we - # overwrite `system.render_template` - context = module.get_html() + fragment = module.runtime.render(module, None, 'student_view') expected_context = { 'caption_asset_path': '/static/subs/', 'sub': module.sub, @@ -122,7 +121,7 @@ class VideoAlphaModuleUnitTest(unittest.TestCase): 'track': module.track, 'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True) } - self.assertDictEqual(context, expected_context) + self.assertEqual(fragment.content, module.runtime.render_template('videoalpha.html', expected_context)) self.assertDictEqual( json.loads(module.get_instance_state()), diff --git a/lms/djangoapps/courseware/tests/test_word_cloud.py b/lms/djangoapps/courseware/tests/test_word_cloud.py index 7c214f3458..6b01ae94f4 100644 --- a/lms/djangoapps/courseware/tests/test_word_cloud.py +++ b/lms/djangoapps/courseware/tests/test_word_cloud.py @@ -242,9 +242,7 @@ class TestWordCloud(BaseTestXmodule): def test_word_cloud_constructor(self): """Make sure that all parameters extracted correclty from xml""" - # `get_html` return only context, cause we - # overwrite `system.render_template` - context = self.item_module.get_html() + fragment = self.runtime.render(self.item_module, None, 'student_view') expected_context = { 'ajax_url': self.item_module.system.ajax_url, @@ -253,4 +251,4 @@ class TestWordCloud(BaseTestXmodule): 'num_inputs': 5, # default value 'submitted': False # default value } - self.assertDictEqual(context, expected_context) + self.assertEqual(fragment.content, self.runtime.render_template('word_cloud.html', expected_context)) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index b675f4dfc3..f152c0833b 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -400,7 +400,7 @@ def index(request, course_id, chapter=None, section=None, # add in the appropriate timer information to the rendering context: context.update(check_for_active_timelimit_module(request, course_id, course)) - context['content'] = section_module.get_html() + context['content'] = section_module.runtime.render(section_module, None, 'student_view').content else: # section is none, so display a message prev_section = get_current_child(chapter_module) From f2977704fe868788462e0a38f4c2924d707a75f4 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Wed, 31 Jul 2013 09:35:21 -0400 Subject: [PATCH 275/556] Remove unused tests which a previous refactoring had replaced. Coverage of locator.py is 100% --- .../modulestore/tests/test_locators.py | 421 ++---------------- 1 file changed, 40 insertions(+), 381 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py index 5c010980b5..bb41131234 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py @@ -4,12 +4,10 @@ Created on Mar 14, 2013 @author: dmitchell ''' from unittest import TestCase -from nose.plugins.skip import SkipTest from bson.objectid import ObjectId from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator -from xmodule.modulestore.exceptions import InvalidLocationError, \ - InsufficientSpecificationError, OverSpecificationError +from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError class LocatorTest(TestCase): @@ -21,25 +19,25 @@ class LocatorTest(TestCase): self.assertRaises( OverSpecificationError, CourseLocator, - url='edx://edu.mit.eecs.6002x', - course_id='edu.harvard.history', + url='edx://mit.eecs.6002x', + course_id='harvard.history', branch='published', version_guid=ObjectId()) self.assertRaises( OverSpecificationError, CourseLocator, - url='edx://edu.mit.eecs.6002x', - course_id='edu.harvard.history', + url='edx://mit.eecs.6002x', + course_id='harvard.history', version_guid=ObjectId()) self.assertRaises( OverSpecificationError, CourseLocator, - url='edx://edu.mit.eecs.6002x;published', + url='edx://mit.eecs.6002x;published', branch='draft') self.assertRaises( OverSpecificationError, CourseLocator, - course_id='edu.mit.eecs.6002x;published', + course_id='mit.eecs.6002x;published', branch='draft') def test_course_constructor_underspecified(self): @@ -73,43 +71,43 @@ class LocatorTest(TestCase): """ Test all sorts of badly-formed course_ids (and urls with those course_ids) """ - for bad_id in ('edu.mit.', - ' edu.mit.eecs', - 'edu.mit.eecs ', - '@edu.mit.eecs', - '#edu.mit.eecs', - 'edu.mit.ee cs', - 'edu.mit.ee,cs', - 'edu.mit.ee/cs', - 'edu.mit.ee$cs', - 'edu.mit.ee&cs', - 'edu.mit.ee()cs', + for bad_id in ('mit.', + ' mit.eecs', + 'mit.eecs ', + '@mit.eecs', + '#mit.eecs', + 'mit.ee cs', + 'mit.ee,cs', + 'mit.ee/cs', + 'mit.ee$cs', + 'mit.ee&cs', + 'mit.ee()cs', ';this', - 'edu.mit.eecs;', - 'edu.mit.eecs;this;that', - 'edu.mit.eecs;this;', - 'edu.mit.eecs;this ', - 'edu.mit.eecs;th%is ', + 'mit.eecs;', + 'mit.eecs;this;that', + 'mit.eecs;this;', + 'mit.eecs;this ', + 'mit.eecs;th%is ', ): self.assertRaises(AssertionError, CourseLocator, course_id=bad_id) self.assertRaises(AssertionError, CourseLocator, url='edx://' + bad_id) def test_course_constructor_bad_url(self): for bad_url in ('edx://', - 'edx:/edu.mit.eecs', - 'http://edu.mit.eecs', - 'edu.mit.eecs', - 'edx//edu.mit.eecs'): + 'edx:/mit.eecs', + 'http://mit.eecs', + 'mit.eecs', + 'edx//mit.eecs'): self.assertRaises(AssertionError, CourseLocator, url=bad_url) def test_course_constructor_redundant_001(self): - testurn = 'edu.mit.eecs.6002x' + testurn = 'mit.eecs.6002x' testobj = CourseLocator(course_id=testurn, url='edx://' + testurn) self.check_course_locn_fields(testobj, 'course_id', course_id=testurn) def test_course_constructor_redundant_002(self): - testurn = 'edu.mit.eecs.6002x;published' - expected_urn = 'edu.mit.eecs.6002x' + testurn = 'mit.eecs.6002x;published' + expected_urn = 'mit.eecs.6002x' expected_rev = 'published' testobj = CourseLocator(course_id=testurn, url='edx://' + testurn) self.check_course_locn_fields(testobj, 'course_id', @@ -117,7 +115,7 @@ class LocatorTest(TestCase): branch=expected_rev) def test_course_constructor_course_id_no_branch(self): - testurn = 'edu.mit.eecs.6002x' + testurn = 'mit.eecs.6002x' testobj = CourseLocator(course_id=testurn) self.check_course_locn_fields(testobj, 'course_id', course_id=testurn) self.assertEqual(testobj.course_id, testurn) @@ -125,8 +123,8 @@ class LocatorTest(TestCase): self.assertEqual(testobj.url(), 'edx://' + testurn) def test_course_constructor_course_id_with_branch(self): - testurn = 'edu.mit.eecs.6002x;published' - expected_id = 'edu.mit.eecs.6002x' + testurn = 'mit.eecs.6002x;published' + expected_id = 'mit.eecs.6002x' expected_branch = 'published' testobj = CourseLocator(course_id=testurn) self.check_course_locn_fields(testobj, 'course_id with branch', @@ -139,9 +137,9 @@ class LocatorTest(TestCase): self.assertEqual(testobj.url(), 'edx://' + testurn) def test_course_constructor_course_id_separate_branch(self): - test_id = 'edu.mit.eecs.6002x' + test_id = 'mit.eecs.6002x' test_branch = 'published' - expected_urn = 'edu.mit.eecs.6002x;published' + expected_urn = 'mit.eecs.6002x;published' testobj = CourseLocator(course_id=test_id, branch=test_branch) self.check_course_locn_fields(testobj, 'course_id with separate branch', course_id=test_id, @@ -156,10 +154,10 @@ class LocatorTest(TestCase): """ The same branch appears in the course_id and the branch field. """ - test_id = 'edu.mit.eecs.6002x;published' + test_id = 'mit.eecs.6002x;published' test_branch = 'published' - expected_id = 'edu.mit.eecs.6002x' - expected_urn = 'edu.mit.eecs.6002x;published' + expected_id = 'mit.eecs.6002x' + expected_urn = 'mit.eecs.6002x;published' testobj = CourseLocator(course_id=test_id, branch=test_branch) self.check_course_locn_fields(testobj, 'course_id with repeated branch', course_id=expected_id, @@ -171,8 +169,8 @@ class LocatorTest(TestCase): self.assertEqual(testobj.url(), 'edx://' + expected_urn) def test_block_constructor(self): - testurn = 'edu.mit.eecs.6002x;published#HW3' - expected_id = 'edu.mit.eecs.6002x' + testurn = 'mit.eecs.6002x;published#HW3' + expected_id = 'mit.eecs.6002x' expected_branch = 'published' expected_block_ref = 'HW3' testobj = BlockUsageLocator(course_id=testurn) @@ -183,345 +181,6 @@ class LocatorTest(TestCase): self.assertEqual(str(testobj), testurn) self.assertEqual(testobj.url(), 'edx://' + testurn) - # ------------------------------------------------------------ - # Disabled tests - - def test_course_urls(self): - ''' - Test constructor and property accessors. - ''' - raise SkipTest() - self.assertRaises(TypeError, CourseLocator, 'empty constructor') - - # url inits - testurn = 'edx://org/course/category/name' - self.assertRaises(InvalidLocationError, CourseLocator, url=testurn) - testurn = 'unknown/versionid/blockid' - self.assertRaises(InvalidLocationError, CourseLocator, url=testurn) - - testurn = 'cvx/versionid' - testobj = CourseLocator(testurn) - self.check_course_locn_fields(testobj, testurn, 'versionid') - self.assertEqual(testobj, CourseLocator(testobj), - 'initialization from another instance') - - testurn = 'cvx/versionid/' - testobj = CourseLocator(testurn) - self.check_course_locn_fields(testobj, testurn, 'versionid') - - testurn = 'cvx/versionid/blockid' - testobj = CourseLocator(testurn) - self.check_course_locn_fields(testobj, testurn, 'versionid') - - testurn = 'cvx/versionid/blockid/extraneousstuff?including=args' - testobj = CourseLocator(testurn) - self.check_course_locn_fields(testobj, testurn, 'versionid') - - testurn = 'cvx://versionid/blockid' - testobj = CourseLocator(testurn) - self.check_course_locn_fields(testobj, testurn, 'versionid') - - testurn = 'crx/courseid/blockid' - testobj = CourseLocator(testurn) - self.check_course_locn_fields(testobj, testurn, course_id='courseid') - - testurn = 'crx/courseid@branch/blockid' - testobj = CourseLocator(testurn) - self.check_course_locn_fields(testobj, testurn, course_id='courseid', - branch='branch') - self.assertEqual(testobj, CourseLocator(testobj), - 'run initialization from another instance') - - def test_course_keyword_setters(self): - raise SkipTest() - # arg list inits - testobj = CourseLocator(version_guid='versionid') - self.check_course_locn_fields(testobj, 'versionid arg', 'versionid') - - testobj = CourseLocator(course_id='courseid') - self.check_course_locn_fields(testobj, 'courseid arg', - course_id='courseid') - - testobj = CourseLocator(course_id='courseid', branch='rev') - self.check_course_locn_fields(testobj, 'rev arg', - course_id='courseid', - branch='rev') - # ignores garbage - testobj = CourseLocator(course_id='courseid', branch='rev', - potato='spud') - self.check_course_locn_fields(testobj, 'extra keyword arg', - course_id='courseid', - branch='rev') - - # url w/ keyword override - testurn = 'crx/courseid@branch/blockid' - testobj = CourseLocator(testurn, branch='rev') - self.check_course_locn_fields(testobj, 'rev override', - course_id='courseid', - branch='rev') - - def test_course_dict(self): - raise SkipTest() - # dict init w/ keyword overwrites - testobj = CourseLocator({"version_guid": 'versionid'}) - self.check_course_locn_fields(testobj, 'versionid dict', 'versionid') - - testobj = CourseLocator({"course_id": 'courseid'}) - self.check_course_locn_fields(testobj, 'courseid dict', - course_id='courseid') - - testobj = CourseLocator({"course_id": 'courseid', "branch": 'rev'}) - self.check_course_locn_fields(testobj, 'rev dict', - course_id='courseid', - branch='rev') - # ignores garbage - testobj = CourseLocator({"course_id": 'courseid', "branch": 'rev', - "potato": 'spud'}) - self.check_course_locn_fields(testobj, 'extra keyword dict', - course_id='courseid', - branch='rev') - testobj = CourseLocator({"course_id": 'courseid', "branch": 'rev'}, - branch='alt') - self.check_course_locn_fields(testobj, 'rev dict', - course_id='courseid', - branch='alt') - - # urn init w/ dict & keyword overwrites - testobj = CourseLocator('crx/notcourse@notthis', - {"course_id": 'courseid'}, - branch='alt') - self.check_course_locn_fields(testobj, 'rev dict', - course_id='courseid', - branch='alt') - - def test_url(self): - ''' - Ensure CourseLocator generates expected urls. - ''' - raise SkipTest() - - testobj = CourseLocator(version_guid='versionid') - self.assertEqual(testobj.url(), 'cvx/versionid', 'versionid') - self.assertEqual(testobj, CourseLocator(testobj.url()), - 'versionid conversion through url') - - testobj = CourseLocator(course_id='courseid') - self.assertEqual(testobj.url(), 'crx/courseid', 'courseid') - self.assertEqual(testobj, CourseLocator(testobj.url()), - 'courseid conversion through url') - - testobj = CourseLocator(course_id='courseid', branch='rev') - self.assertEqual(testobj.url(), 'crx/courseid@rev', 'rev') - self.assertEqual(testobj, CourseLocator(testobj.url()), - 'rev conversion through url') - - def test_html(self): - ''' - Ensure CourseLocator generates expected urls. - ''' - raise SkipTest() - testobj = CourseLocator(version_guid='versionid') - self.assertEqual(testobj.html_id(), 'cvx/versionid', 'versionid') - self.assertEqual(testobj, CourseLocator(testobj.html_id()), - 'versionid conversion through html_id') - - testobj = CourseLocator(course_id='courseid') - self.assertEqual(testobj.html_id(), 'crx/courseid', 'courseid') - self.assertEqual(testobj, CourseLocator(testobj.html_id()), - 'courseid conversion through html_id') - - testobj = CourseLocator(course_id='courseid', branch='rev') - self.assertEqual(testobj.html_id(), 'crx/courseid%40rev', 'rev') - self.assertEqual(testobj, CourseLocator(testobj.html_id()), - 'rev conversion through html_id') - - def test_block_locator(self): - ''' - Test constructor and property accessors. - ''' - raise SkipTest() - self.assertIsInstance(BlockUsageLocator(), BlockUsageLocator, - 'empty constructor') - - # url inits - testurn = 'edx://org/course/category/name' - self.assertRaises(InvalidLocationError, BlockUsageLocator, testurn) - testurn = 'unknown/versionid/blockid' - self.assertRaises(InvalidLocationError, BlockUsageLocator, testurn) - - testurn = 'cvx/versionid' - testobj = BlockUsageLocator(testurn) - self.check_block_locn_fields(testobj, testurn, 'versionid') - self.assertEqual(testobj, BlockUsageLocator(testobj), - 'initialization from another instance') - - testurn = 'cvx/versionid/' - testobj = BlockUsageLocator(testurn) - self.check_block_locn_fields(testobj, testurn, 'versionid') - - testurn = 'cvx/versionid/blockid' - testobj = BlockUsageLocator(testurn) - self.check_block_locn_fields(testobj, testurn, 'versionid', - block='blockid') - - testurn = 'cvx/versionid/blockid/extraneousstuff?including=args' - testobj = BlockUsageLocator(testurn) - self.check_block_locn_fields(testobj, testurn, 'versionid', - block='blockid') - - testurn = 'cvx://versionid/blockid' - testobj = BlockUsageLocator(testurn) - self.check_block_locn_fields(testobj, testurn, 'versionid', - block='blockid') - - testurn = 'crx/courseid/blockid' - testobj = BlockUsageLocator(testurn) - self.check_block_locn_fields(testobj, testurn, course_id='courseid', - block='blockid') - - testurn = 'crx/courseid@branch/blockid' - testobj = BlockUsageLocator(testurn) - self.check_block_locn_fields(testobj, testurn, course_id='courseid', - branch='branch', block='blockid') - self.assertEqual(testobj, BlockUsageLocator(testobj), - 'run initialization from another instance') - - def test_block_keyword_init(self): - # arg list inits - raise SkipTest() - testobj = BlockUsageLocator(version_guid='versionid') - self.check_block_locn_fields(testobj, 'versionid arg', 'versionid') - - testobj = BlockUsageLocator(version_guid='versionid', usage_id='myblock') - self.check_block_locn_fields(testobj, 'versionid arg', 'versionid', - block='myblock') - - testobj = BlockUsageLocator(course_id='courseid') - self.check_block_locn_fields(testobj, 'courseid arg', - course_id='courseid') - - testobj = BlockUsageLocator(course_id='courseid', branch='rev') - self.check_block_locn_fields(testobj, 'rev arg', - course_id='courseid', - branch='rev') - # ignores garbage - testobj = BlockUsageLocator(course_id='courseid', branch='rev', - usage_id='this_block', potato='spud') - self.check_block_locn_fields(testobj, 'extra keyword arg', - course_id='courseid', block='this_block', branch='rev') - - # url w/ keyword override - testurn = 'crx/courseid@branch/blockid' - testobj = BlockUsageLocator(testurn, branch='rev') - self.check_block_locn_fields(testobj, 'rev override', - course_id='courseid', block='blockid', - branch='rev') - - def test_block_keywords(self): - # dict init w/ keyword overwrites - raise SkipTest() - testobj = BlockUsageLocator({"version_guid": 'versionid', - 'usage_id': 'dictblock'}) - self.check_block_locn_fields(testobj, 'versionid dict', 'versionid', - block='dictblock') - - testobj = BlockUsageLocator({"course_id": 'courseid', - 'usage_id': 'dictblock'}) - self.check_block_locn_fields(testobj, 'courseid dict', - block='dictblock', course_id='courseid') - - testobj = BlockUsageLocator({"course_id": 'courseid', "branch": 'rev', - 'usage_id': 'dictblock'}) - self.check_block_locn_fields(testobj, 'rev dict', - course_id='courseid', block='dictblock', - branch='rev') - # ignores garbage - testobj = BlockUsageLocator({"course_id": 'courseid', "branch": 'rev', - 'usage_id': 'dictblock', "potato": 'spud'}) - self.check_block_locn_fields(testobj, 'extra keyword dict', - course_id='courseid', block='dictblock', - branch='rev') - testobj = BlockUsageLocator({"course_id": 'courseid', "branch": 'rev', - 'usage_id': 'dictblock'}, branch='alt', usage_id='anotherblock') - self.check_block_locn_fields(testobj, 'rev dict', - course_id='courseid', block='anotherblock', - branch='alt') - - # urn init w/ dict & keyword overwrites - testobj = BlockUsageLocator('crx/notcourse@notthis/northis', - {"course_id": 'courseid'}, branch='alt', usage_id='anotherblock') - self.check_block_locn_fields(testobj, 'rev dict', - course_id='courseid', block='anotherblock', - branch='alt') - - def test_ensure_fully_specd(self): - ''' - Test constructor and property accessors. - ''' - raise SkipTest() - self.assertRaises(InsufficientSpecificationError, - BlockUsageLocator.ensure_fully_specified, BlockUsageLocator()) - - # url inits - testurn = 'edx://org/course/category/name' - self.assertRaises(InvalidLocationError, - BlockUsageLocator.ensure_fully_specified, testurn) - testurn = 'unknown/versionid/blockid' - self.assertRaises(InvalidLocationError, - BlockUsageLocator.ensure_fully_specified, testurn) - - testurn = 'cvx/versionid' - self.assertRaises(InsufficientSpecificationError, - BlockUsageLocator.ensure_fully_specified, testurn) - - testurn = 'cvx/versionid/' - self.assertRaises(InsufficientSpecificationError, - BlockUsageLocator.ensure_fully_specified, testurn) - - testurn = 'cvx/versionid/blockid' - self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), - BlockUsageLocator, testurn) - - testurn = 'cvx/versionid/blockid/extraneousstuff?including=args' - self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), - BlockUsageLocator, testurn) - - testurn = 'cvx://versionid/blockid' - self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), - BlockUsageLocator, testurn) - - testurn = 'crx/courseid/blockid' - self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), - BlockUsageLocator, testurn) - - testurn = 'crx/courseid@branch/blockid' - self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), - BlockUsageLocator, testurn) - - def test_ensure_fully_via_keyword(self): - # arg list inits - raise SkipTest() - testobj = BlockUsageLocator(version_guid='versionid') - self.assertRaises(InsufficientSpecificationError, - BlockUsageLocator.ensure_fully_specified, testobj) - - testurn = 'crx/courseid@branch/blockid' - testobj = BlockUsageLocator(version_guid='versionid', usage_id='myblock') - self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), - BlockUsageLocator, testurn) - - testobj = BlockUsageLocator(course_id='courseid') - self.assertRaises(InsufficientSpecificationError, - BlockUsageLocator.ensure_fully_specified, testobj) - - testobj = BlockUsageLocator(course_id='courseid', branch='rev') - self.assertRaises(InsufficientSpecificationError, - BlockUsageLocator.ensure_fully_specified, testobj) - - testobj = BlockUsageLocator(course_id='courseid', branch='rev', - usage_id='this_block') - self.assertIsInstance(BlockUsageLocator.ensure_fully_specified(testurn), - BlockUsageLocator, testurn) # ------------------------------------------------------------------ # Utilities From 4cb8f58b2740e163cd0df32234d5e4be84a64b6b Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 30 Jul 2013 10:13:15 -0400 Subject: [PATCH 276/556] Quiet mongoimport noise during tests --- .../modulestore/tests/test_split_modulestore.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index 29f6cce919..e2eea9c1aa 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -69,10 +69,17 @@ class SplitModuleTest(unittest.TestCase): collection_prefix + collection, '--jsonArray', '--file', SplitModuleTest.COMMON_ROOT + '/test/data/splitmongo_json/' + collection + '.json' - ]) + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) for collection in ('active_versions', 'structures', 'definitions')] for p in processes: - if p.wait() != 0: + stdout, stderr = p.communicate() + if p.returncode != 0: + print "Couldn't run mongoimport:" + print stdout + print stderr raise Exception("DB did not init correctly") @classmethod From 68e1bcf42ed85e426fc20e4071cfa147a2d5dd9f Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Wed, 31 Jul 2013 11:24:51 -0400 Subject: [PATCH 277/556] Remove edu from doc string examples & typos --- .../lib/xmodule/xmodule/modulestore/locator.py | 18 +++++++++--------- .../tests/test_split_modulestore.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py index b7d3a3b9fb..591ef3115f 100644 --- a/common/lib/xmodule/xmodule/modulestore/locator.py +++ b/common/lib/xmodule/xmodule/modulestore/locator.py @@ -45,7 +45,7 @@ class Locator(object): def __repr__(self): ''' - repr(self) returns something like this: CourseLocator("edu.mit.eecs.6002x") + repr(self) returns something like this: CourseLocator("mit.eecs.6002x") ''' classname = self.__class__.__name__ if classname.find('.') != -1: @@ -54,13 +54,13 @@ class Locator(object): def __str__(self): ''' - str(self) returns something like this: "edu.mit.eecs.6002x" + str(self) returns something like this: "mit.eecs.6002x" ''' return unicode(self).encode('utf8') def __unicode__(self): ''' - unicode(self) returns something like this: "edu.mit.eecs.6002x" + unicode(self) returns something like this: "mit.eecs.6002x" ''' return self.url() @@ -89,12 +89,12 @@ class CourseLocator(Locator): """ Examples of valid CourseLocator specifications: CourseLocator(version_guid=ObjectId('519665f6223ebd6980884f2b')) - CourseLocator(course_id='edu.mit.eecs.6002x') - CourseLocator(course_id='edu.mit.eecs.6002x;published') - CourseLocator(course_id='edu.mit.eecs.6002x', branch='published') + CourseLocator(course_id='mit.eecs.6002x') + CourseLocator(course_id='mit.eecs.6002x;published') + CourseLocator(course_id='mit.eecs.6002x', branch='published') CourseLocator(url='edx://@519665f6223ebd6980884f2b') - CourseLocator(url='edx://edu.mit.eecs.6002x') - CourseLocator(url='edx://edu.mit.eecs.6002x;published') + CourseLocator(url='edx://mit.eecs.6002x') + CourseLocator(url='edx://mit.eecs.6002x;published') Should have at lease a specific course_id (id for the course as if it were a project w/ versions) with optional 'branch', @@ -253,7 +253,7 @@ class CourseLocator(Locator): def init_from_course_id(self, course_id, explicit_branch=None): """ - Course_id is a string like 'edu.mit.eecs.6002x' or 'edu.mit.eecs.6002x;published'. + Course_id is a string like 'mit.eecs.6002x' or 'mit.eecs.6002x;published'. Revision (optional) is a string like 'published'. It may be provided explicitly (explicit_branch) or embedded into course_id. diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index 4d12594be5..6012b154a4 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -331,7 +331,7 @@ class SplitModuleItemTests(SplitModuleTest): block.grade_cutoffs, {"Pass": 0.45}, ) - # try to look up other branchs + # try to look up other branches self.assertRaises(ItemNotFoundError, modulestore().get_item, BlockUsageLocator(course_id=locator.as_course_locator(), From b85f92de8d9f49b07bc6d41ac3147cca8877b1f9 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 31 Jul 2013 11:29:00 -0400 Subject: [PATCH 278/556] Studio: wraps new course creation form in authorship rights logic --- cms/templates/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cms/templates/index.html b/cms/templates/index.html index 5280e2ed96..59f54fe6ab 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -79,7 +79,8 @@ %endif

    -
    + % if course_creator_status=='granted': +
    -
    + % endif %if len(courses) > 0:
    From 9ab21be98c5b6bb35487efcb8cdc1df8a2ce805b Mon Sep 17 00:00:00 2001 From: Frances Botsford Date: Wed, 31 Jul 2013 12:17:21 -0400 Subject: [PATCH 279/556] fix for ie bug where drop down menus were still clickable when collapsed --- cms/static/sass/elements/_navigation.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index d05965d83c..739d091b8e 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -64,12 +64,14 @@ nav { opacity: 0.0; pointer-events: none; width: ($baseline*8); + overflow: hidden; // dropped down state &.is-shown { opacity: 1.0; pointer-events: auto; + overflow: visible; } } From b64aa813c89742f8cb589244eeba7cfc33957818 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 31 Jul 2013 12:19:33 -0400 Subject: [PATCH 280/556] Make render_template mocking clearer --- common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py | 6 +++--- lms/djangoapps/courseware/tests/__init__.py | 2 +- lms/djangoapps/courseware/tests/test_videoalpha_xml.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py index 39dc6afe0e..90ff209c7d 100644 --- a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py +++ b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py @@ -66,7 +66,7 @@ class TestXBlockWrapper(object): @property def leaf_module_runtime(self): runtime = Mock() - runtime.render_template = lambda *args, **kwargs: unicode((args, kwargs)) + runtime.render_template = lambda *args, **kwargs: u'{!r}, {!r}'.format(args, kwargs) runtime.anonymous_student_id = 'dummy_anonymous_student_id' runtime.open_ended_grading_interface = {} runtime.seed = 5 @@ -78,7 +78,7 @@ class TestXBlockWrapper(object): @property def leaf_descriptor_runtime(self): runtime = Mock() - runtime.render_template = lambda *args, **kwargs: unicode((args, kwargs)) + runtime.render_template = lambda *args, **kwargs: u'{!r}, {!r}'.format(args, kwargs) return runtime def leaf_descriptor(self, descriptor_cls): @@ -102,7 +102,7 @@ class TestXBlockWrapper(object): @property def container_descriptor_runtime(self): runtime = Mock() - runtime.render_template = lambda *args, **kwargs: unicode((args, kwargs)) + runtime.render_template = lambda *args, **kwargs: u'{!r}, {!r}'.format(args, kwargs) return runtime def container_descriptor(self, descriptor_cls): diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 3169606a7b..cdb8bb8ea8 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -80,7 +80,7 @@ class BaseTestXmodule(ModuleStoreTestCase): self.runtime = get_test_system() # Allow us to assert that the template was called in the same way from # different code paths while maintaining the type returned by render_template - self.runtime.render_template = lambda template, context: unicode((template, sorted(context.items()))) + self.runtime.render_template = lambda template, context: u'{!r}, {!r}'.format(template, sorted(context.items())) model_data = {'location': self.item_descriptor.location} model_data.update(self.MODEL_DATA) diff --git a/lms/djangoapps/courseware/tests/test_videoalpha_xml.py b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py index ae49be86dc..6df957a0e5 100644 --- a/lms/djangoapps/courseware/tests/test_videoalpha_xml.py +++ b/lms/djangoapps/courseware/tests/test_videoalpha_xml.py @@ -104,7 +104,7 @@ class VideoAlphaModuleUnitTest(unittest.TestCase): def test_videoalpha_constructor(self): """Make sure that all parameters extracted correclty from xml""" module = VideoAlphaFactory.create() - module.runtime.render_template = lambda template, context: unicode((template, sorted(context.items()))) + module.runtime.render_template = lambda template, context: u'{!r}, {!r}'.format(template, sorted(context.items())) fragment = module.runtime.render(module, None, 'student_view') expected_context = { From 635d36fcf9eaaf5afb195d39768fa5af2a856ff3 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Tue, 23 Jul 2013 18:13:43 -0400 Subject: [PATCH 281/556] Add audit log definition, and use for logging of logins in external_auth and student apps. Move test_login to student app. Improve conditional tests for Shibboleth login logic. (Does not include reconfiguring log settings.) --- CHANGELOG.rst | 2 + .../external_auth/tests/test_shib.py | 117 +++++++--- common/djangoapps/external_auth/views.py | 210 +++++++++--------- common/djangoapps/student/models.py | 19 ++ common/djangoapps/student/tests/test_login.py | 143 ++++++++++++ common/djangoapps/student/views.py | 57 +++-- lms/djangoapps/branding/views.py | 2 +- lms/djangoapps/courseware/tests/test_login.py | 107 --------- 8 files changed, 397 insertions(+), 260 deletions(-) create mode 100644 common/djangoapps/student/tests/test_login.py delete mode 100644 lms/djangoapps/courseware/tests/test_login.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 51a98f2de7..f1663f4139 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Common: Add additional logging to cover login attempts and logouts. + Studio: Send e-mails to new Studio users (on edge only) when their course creator status has changed. This will not be in use until the course creator table is enabled. diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py index e46c9eda8f..428119b886 100644 --- a/common/djangoapps/external_auth/tests/test_shib.py +++ b/common/djangoapps/external_auth/tests/test_shib.py @@ -3,6 +3,7 @@ Tests for Shibboleth Authentication @jbau """ import unittest +from mock import patch from django.conf import settings from django.http import HttpResponseRedirect @@ -10,7 +11,6 @@ from django.test.client import RequestFactory, Client as DjangoTestClient from django.test.utils import override_settings from django.core.urlresolvers import reverse from django.contrib.auth.models import AnonymousUser, User -from django.contrib.sessions.backends.base import SessionBase from django.utils.importlib import import_module from xmodule.modulestore.tests.factories import CourseFactory @@ -27,11 +27,11 @@ from student.views import create_account, change_enrollment from student.models import UserProfile, Registration, CourseEnrollment from student.tests.factories import UserFactory -#Shib is supposed to provide 'REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider' -#attributes via request.META. We can count on 'Shib-Identity-Provider', and 'REMOTE_USER' being present -#b/c of how mod_shib works but should test the behavior with the rest of the attributes present/missing +# Shib is supposed to provide 'REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider' +# attributes via request.META. We can count on 'Shib-Identity-Provider', and 'REMOTE_USER' being present +# b/c of how mod_shib works but should test the behavior with the rest of the attributes present/missing -#For the sake of python convention we'll make all of these variable names ALL_CAPS +# For the sake of python convention we'll make all of these variable names ALL_CAPS IDP = 'https://idp.stanford.edu/' REMOTE_USER = 'test_user@stanford.edu' MAILS = [None, '', 'test_user@stanford.edu'] @@ -93,6 +93,13 @@ class ShibSPTest(ModuleStoreTestCase): self.assertEqual(no_idp_response.status_code, 403) self.assertIn("identity server did not return your ID information", no_idp_response.content) + def _assert_shib_login_is_logged(self, audit_log_call, remote_user): + """Asserts that shibboleth login attempt is being logged""" + method_name, args, _kwargs = audit_log_call + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 2) + self.assertIn(u'logged in via Shibboleth', args[0]) + self.assertEquals(remote_user, args[1]) @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) def test_shib_login(self): @@ -140,26 +147,57 @@ class ShibSPTest(ModuleStoreTestCase): 'REMOTE_USER': remote_user, 'mail': remote_user}) request.user = AnonymousUser() - response = shib_login(request) + with patch('external_auth.views.AUDIT_LOG') as mock_audit_log: + response = shib_login(request) + audit_log_calls = mock_audit_log.method_calls + if idp == "https://idp.stanford.edu/" and remote_user == 'withmap@stanford.edu': self.assertIsInstance(response, HttpResponseRedirect) self.assertEqual(request.user, user_w_map) self.assertEqual(response['Location'], '/') + # verify logging: + self.assertEquals(len(audit_log_calls), 2) + self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 3) + self.assertIn(u'Login success', args[0]) + self.assertEquals(remote_user, args[2]) elif idp == "https://idp.stanford.edu/" and remote_user == 'inactive@stanford.edu': self.assertEqual(response.status_code, 403) self.assertIn("Account not yet activated: please look for link in your email", response.content) + # verify logging: + self.assertEquals(len(audit_log_calls), 2) + self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'warning') + self.assertEquals(len(args), 2) + self.assertIn(u'is not active after external login', args[0]) + # self.assertEquals(remote_user, args[1]) elif idp == "https://idp.stanford.edu/" and remote_user == 'womap@stanford.edu': self.assertIsNotNone(ExternalAuthMap.objects.get(user=user_wo_map)) self.assertIsInstance(response, HttpResponseRedirect) self.assertEqual(request.user, user_wo_map) self.assertEqual(response['Location'], '/') + # verify logging: + self.assertEquals(len(audit_log_calls), 2) + self._assert_shib_login_is_logged(audit_log_calls[0], remote_user) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 3) + self.assertIn(u'Login success', args[0]) + self.assertEquals(remote_user, args[2]) elif idp == "https://someother.idp.com/" and remote_user in \ ['withmap@stanford.edu', 'womap@stanford.edu', 'inactive@stanford.edu']: self.assertEqual(response.status_code, 403) self.assertIn("You have already created an account using an external login", response.content) + # no audit logging calls + self.assertEquals(len(audit_log_calls), 0) else: self.assertEqual(response.status_code, 200) self.assertContains(response, "Register for") + # no audit logging calls + self.assertEquals(len(audit_log_calls), 0) @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) def test_registration_form(self): @@ -187,7 +225,7 @@ class ShibSPTest(ModuleStoreTestCase): else: self.assertNotContains(response, fullname_input_HTML) - #clean up b/c we don't want existing ExternalAuthMap for the next run + # clean up b/c we don't want existing ExternalAuthMap for the next run client.session['ExternalAuthMap'].delete() @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) @@ -200,25 +238,47 @@ class ShibSPTest(ModuleStoreTestCase): Uses django test client for its session support """ for identity in gen_all_identities(): - #First we pop the registration form + # First we pop the registration form client = DjangoTestClient() response1 = client.get(path='/shib-login/', data={}, follow=False, **identity) - #Then we have the user answer the registration form + # Then we have the user answer the registration form postvars = {'email': 'post_email@stanford.edu', 'username': 'post_username', 'password': 'post_password', 'name': 'post_name', 'terms_of_service': 'true', 'honor_code': 'true'} - #use RequestFactory instead of TestClient here because we want access to request.user + # use RequestFactory instead of TestClient here because we want access to request.user request2 = self.request_factory.post('/create_account', data=postvars) request2.session = client.session request2.user = AnonymousUser() - response2 = create_account(request2) + with patch('student.views.AUDIT_LOG') as mock_audit_log: + _response2 = create_account(request2) user = request2.user mail = identity.get('mail') - #check that the created user has the right email, either taken from shib or user input + + # verify logging of login happening during account creation: + audit_log_calls = mock_audit_log.method_calls + self.assertEquals(len(audit_log_calls), 3) + method_name, args, _kwargs = audit_log_calls[0] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 1) + self.assertIn(u'Login success on new account creation', args[0]) + self.assertIn(u'post_username', args[0]) + method_name, args, _kwargs = audit_log_calls[1] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 2) + self.assertIn(u'User registered with external_auth', args[0]) + self.assertEquals(u'post_username', args[1]) + method_name, args, _kwargs = audit_log_calls[2] + self.assertEquals(method_name, 'info') + self.assertEquals(len(args), 3) + self.assertIn(u'Updated ExternalAuthMap for ', args[0]) + self.assertEquals(u'post_username', args[1]) + self.assertEquals(u'test_user@stanford.edu', args[2].external_id) + + # check that the created user has the right email, either taken from shib or user input if mail: self.assertEqual(user.email, mail) self.assertEqual(list(User.objects.filter(email=postvars['email'])), []) @@ -228,7 +288,7 @@ class ShibSPTest(ModuleStoreTestCase): self.assertEqual(list(User.objects.filter(email=mail)), []) self.assertIsNotNone(User.objects.get(email=postvars['email'])) # get enforces only 1 such user - #check that the created user profile has the right name, either taken from shib or user input + # check that the created user profile has the right name, either taken from shib or user input profile = UserProfile.objects.get(user=user) sn_empty = not identity.get('sn') given_name_empty = not identity.get('givenName') @@ -236,7 +296,7 @@ class ShibSPTest(ModuleStoreTestCase): self.assertEqual(profile.name, postvars['name']) else: self.assertEqual(profile.name, request2.session['ExternalAuthMap'].external_name) - #clean up for next loop + # clean up for next loop request2.session['ExternalAuthMap'].delete() UserProfile.objects.filter(user=user).delete() Registration.objects.filter(user=user).delete() @@ -251,17 +311,17 @@ class ShibSPTest(ModuleStoreTestCase): # Test for cases where course is found for domain in ["", "shib:https://idp.stanford.edu/"]: - #set domains + # set domains course.enrollment_domain = domain metadata = own_metadata(course) metadata['enrollment_domain'] = domain self.store.update_metadata(course.location.url(), metadata) - #setting location to test that GET params get passed through + # setting location to test that GET params get passed through login_request = self.request_factory.get('/course_specific_login/MITx/999/Robot_Super_Course' + '?course_id=MITx/999/Robot_Super_Course' + '&enrollment_action=enroll') - reg_request = self.request_factory.get('/course_specific_register/MITx/999/Robot_Super_Course' + + _reg_request = self.request_factory.get('/course_specific_register/MITx/999/Robot_Super_Course' + '?course_id=MITx/999/course/Robot_Super_Course' + '&enrollment_action=enroll') @@ -292,11 +352,11 @@ class ShibSPTest(ModuleStoreTestCase): '&enrollment_action=enroll') # Now test for non-existent course - #setting location to test that GET params get passed through + # setting location to test that GET params get passed through login_request = self.request_factory.get('/course_specific_login/DNE/DNE/DNE' + '?course_id=DNE/DNE/DNE' + '&enrollment_action=enroll') - reg_request = self.request_factory.get('/course_specific_register/DNE/DNE/DNE' + + _reg_request = self.request_factory.get('/course_specific_register/DNE/DNE/DNE' + '?course_id=DNE/DNE/DNE/Robot_Super_Course' + '&enrollment_action=enroll') @@ -321,7 +381,7 @@ class ShibSPTest(ModuleStoreTestCase): the proper external auth """ - #create 2 course, one with limited enrollment one without + # create 2 course, one with limited enrollment one without shib_course = CourseFactory.create(org='Stanford', number='123', display_name='Shib Only') shib_course.enrollment_domain = 'shib:https://idp.stanford.edu/' metadata = own_metadata(shib_course) @@ -360,7 +420,7 @@ class ShibSPTest(ModuleStoreTestCase): int_student.email = "teststudent3@gmail.com" int_student.save() - #Tests the two case for courses, limited and not + # Tests the two case for courses, limited and not for course in [shib_course, open_enroll_course]: for student in [shib_student, other_ext_student, int_student]: request = self.request_factory.post('/change_enrollment') @@ -368,11 +428,11 @@ class ShibSPTest(ModuleStoreTestCase): 'course_id': course.id}) request.user = student response = change_enrollment(request) - #if course is not limited or student has correct shib extauth then enrollment should be allowed + # If course is not limited or student has correct shib extauth then enrollment should be allowed if course is open_enroll_course or student is shib_student: self.assertEqual(response.status_code, 200) self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1) - #clean up + # Clean up CourseEnrollment.objects.filter(user=student, course_id=course.id).delete() else: self.assertEqual(response.status_code, 400) @@ -383,9 +443,6 @@ class ShibSPTest(ModuleStoreTestCase): """ A functionality test that a student with an existing shib login can auto-enroll in a class with GET params """ - if not settings.MITX_FEATURES.get('AUTH_USE_SHIB'): - return - student = UserFactory.create() extauth = ExternalAuthMap(external_id='testuser@stanford.edu', external_email='', @@ -403,8 +460,8 @@ class ShibSPTest(ModuleStoreTestCase): metadata['enrollment_domain'] = course.enrollment_domain self.store.update_metadata(course.location.url(), metadata) - #use django test client for sessions and url processing - #no enrollment before trying + # use django test client for sessions and url processing + # no enrollment before trying self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 0) self.client.logout() request_kwargs = {'path': '/shib-login/', @@ -413,8 +470,8 @@ class ShibSPTest(ModuleStoreTestCase): 'REMOTE_USER': 'testuser@stanford.edu', 'Shib-Identity-Provider': 'https://idp.stanford.edu/'} response = self.client.get(**request_kwargs) - #successful login is a redirect to "/" + # successful login is a redirect to "/" self.assertEqual(response.status_code, 302) self.assertEqual(response['location'], 'http://testserver/') - #now there is enrollment + # now there is enrollment self.assertEqual(CourseEnrollment.objects.filter(user=student, course_id=course.id).count(), 1) diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 059a915168..9065ea78d6 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -17,9 +17,9 @@ from django.core.urlresolvers import reverse from django.core.validators import validate_email from django.core.exceptions import ValidationError -from student.models import UserProfile, TestCenterUser, TestCenterRegistration +from student.models import TestCenterUser, TestCenterRegistration -from django.http import HttpResponse, HttpResponseRedirect, HttpRequest +from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden from django.utils.http import urlquote from django.shortcuts import redirect from django.utils.translation import ugettext as _ @@ -50,7 +50,10 @@ from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError log = logging.getLogger("mitx.external_auth") +AUDIT_LOG = logging.getLogger("audit") +SHIBBOLETH_DOMAIN_PREFIX = 'shib:' +OPENID_DOMAIN_PREFIX = 'openid:' # ----------------------------------------------------------------------------- # OpenID Common @@ -81,7 +84,7 @@ def default_render_failure(request, def generate_password(length=12, chars=string.letters + string.digits): """Generate internal password for externally authenticated user""" choice = random.SystemRandom().choice - return ''.join([choice(chars) for i in range(length)]) + return ''.join([choice(chars) for _i in range(length)]) @csrf_exempt @@ -105,27 +108,29 @@ def openid_login_complete(request, log.debug('openid success, details=%s', details) url = getattr(settings, 'OPENID_SSO_SERVER_URL', None) - external_domain = "openid:%s" % url + external_domain = "{0}{1}".format(OPENID_DOMAIN_PREFIX, url) fullname = '%s %s' % (details.get('first_name', ''), details.get('last_name', '')) - return external_login_or_signup(request, - external_id, - external_domain, - details, - details.get('email', ''), - fullname) + return _external_login_or_signup( + request, + external_id, + external_domain, + details, + details.get('email', ''), + fullname + ) return render_failure(request, 'Openid failure') -def external_login_or_signup(request, - external_id, - external_domain, - credentials, - email, - fullname, - retfun=None): +def _external_login_or_signup(request, + external_id, + external_domain, + credentials, + email, + fullname, + retfun=None): """Generic external auth login or signup""" # see if we have a map from this external_id to an edX username @@ -142,13 +147,13 @@ def external_login_or_signup(request, eamap.external_name = fullname eamap.internal_password = generate_password() log.debug('Created eamap=%s', eamap) - eamap.save() log.info(u"External_Auth login_or_signup for %s : %s : %s : %s", external_domain, external_id, email, fullname) + uses_shibboleth = settings.MITX_FEATURES.get('AUTH_USE_SHIB') and external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX) internal_user = eamap.user if internal_user is None: - if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): + if uses_shibboleth: # if we are using shib, try to link accounts using email try: link_user = User.objects.get(email=eamap.external_email) @@ -169,14 +174,14 @@ def external_login_or_signup(request, return default_render_failure(request, failure_msg) except User.DoesNotExist: log.info('SHIB: No user for %s yet, doing signup', eamap.external_email) - return signup(request, eamap) + return _signup(request, eamap) else: log.info('No user for %s yet. doing signup', eamap.external_email) - return signup(request, eamap) + return _signup(request, eamap) # We trust shib's authentication, so no need to authenticate using the password again - if settings.MITX_FEATURES.get('AUTH_USE_SHIB'): - uname = internal_user.username + uname = internal_user.username + if uses_shibboleth: user = internal_user # Assuming this 'AUTHENTICATION_BACKENDS' is set in settings, which I think is safe if settings.AUTHENTICATION_BACKENDS: @@ -184,32 +189,32 @@ def external_login_or_signup(request, else: auth_backend = 'django.contrib.auth.backends.ModelBackend' user.backend = auth_backend - log.info('SHIB: Logging in linked user %s', user.email) + AUDIT_LOG.info('Linked user "%s" logged in via Shibboleth', user.email) else: - uname = internal_user.username user = authenticate(username=uname, password=eamap.internal_password) if user is None: - log.warning("External Auth Login failed for %s / %s", - uname, eamap.internal_password) - return signup(request, eamap) + # we want to log the failure, but don't want to log the password attempted: + AUDIT_LOG.warning('External Auth Login failed for "%s"', uname) + return _signup(request, eamap) if not user.is_active: - log.warning("User %s is not active", uname) + AUDIT_LOG.warning('User "%s" is not active after external login', uname) # TODO: improve error page msg = 'Account not yet activated: please look for link in your email' return default_render_failure(request, msg) + login(request, user) request.session.set_expiry(0) # Now to try enrollment # Need to special case Shibboleth here because it logs in via a GET. # testing request.method for extra paranoia - if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in external_domain and request.method == 'GET': - enroll_request = make_shib_enrollment_request(request) + if uses_shibboleth and request.method == 'GET': + enroll_request = _make_shib_enrollment_request(request) student_views.try_change_enrollment(enroll_request) else: student_views.try_change_enrollment(request) - log.info("Login success - %s (%s)", user.username, user.email) + AUDIT_LOG.info("Login success - %s (%s)", user.username, user.email) if retfun is None: return redirect('/') return retfun() @@ -217,20 +222,16 @@ def external_login_or_signup(request, @ensure_csrf_cookie @cache_if_anonymous -def signup(request, eamap=None): +def _signup(request, eamap): """ Present form to complete for signup via external authentication. Even though the user has external credentials, he/she still needs to create an account on the edX system, and fill in the user registration form. - eamap is an ExteralAuthMap object, specifying the external user + eamap is an ExternalAuthMap object, specifying the external user for which to complete the signup. """ - - if eamap is None: - pass - # save this for use by student.views.create_account request.session['ExternalAuthMap'] = eamap @@ -248,8 +249,9 @@ def signup(request, eamap=None): # Some openEdX instances can't have terms of service for shib users, like # according to Stanford's Office of General Counsel - if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS') and \ - ('shib' in eamap.external_domain): + uses_shibboleth = (settings.MITX_FEATURES.get('AUTH_USE_SHIB') and + eamap.external_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX)) + if uses_shibboleth and settings.MITX_FEATURES.get('SHIB_DISABLE_TOS'): context['ask_for_tos'] = False # detect if full name is blank and ask for it from user @@ -272,19 +274,19 @@ def signup(request, eamap=None): # ----------------------------------------------------------------------------- -def ssl_dn_extract_info(dn): +def _ssl_dn_extract_info(dn_string): """ Extract username, email address (may be anyuser@anydomain.com) and full name from the SSL DN string. Return (user,email,fullname) if successful, and None otherwise. """ - ss = re.search('/emailAddress=(.*)@([^/]+)', dn) + ss = re.search('/emailAddress=(.*)@([^/]+)', dn_string) if ss: user = ss.group(1) email = "%s@%s" % (user, ss.group(2)) else: return None - ss = re.search('/CN=([^/]+)/', dn) + ss = re.search('/CN=([^/]+)/', dn_string) if ss: fullname = ss.group(1) else: @@ -292,7 +294,7 @@ def ssl_dn_extract_info(dn): return (user, email, fullname) -def ssl_get_cert_from_request(request): +def _ssl_get_cert_from_request(request): """ Extract user information from certificate, if it exists, returning (user, email, fullname). Else return None. @@ -311,9 +313,6 @@ def ssl_get_cert_from_request(request): return cert - (user, email, fullname) = ssl_dn_extract_info(cert) - return (user, email, fullname) - def ssl_login_shortcut(fn): """ @@ -324,24 +323,26 @@ def ssl_login_shortcut(fn): if not settings.MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES']: return fn(*args, **kwargs) request = args[0] - cert = ssl_get_cert_from_request(request) + cert = _ssl_get_cert_from_request(request) if not cert: # no certificate information - show normal login window return fn(*args, **kwargs) - (user, email, fullname) = ssl_dn_extract_info(cert) - return external_login_or_signup(request, - external_id=email, - external_domain="ssl:MIT", - credentials=cert, - email=email, - fullname=fullname) + (_user, email, fullname) = _ssl_dn_extract_info(cert) + return _external_login_or_signup( + request, + external_id=email, + external_domain="ssl:MIT", + credentials=cert, + email=email, + fullname=fullname + ) return wrapped @csrf_exempt def ssl_login(request): """ - This is called by student.views.index when + This is called by branding.views.index when MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True Used for MIT user authentication. This presumes the web server @@ -355,22 +356,28 @@ def ssl_login(request): Else continues on with student.views.index, and no authentication. """ - cert = ssl_get_cert_from_request(request) + # Just to make sure we're calling this only at MIT: + if not settings.MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES']: + return HttpResponseForbidden() + + cert = _ssl_get_cert_from_request(request) if not cert: # no certificate information - go onward to main index return student_views.index(request) - (user, email, fullname) = ssl_dn_extract_info(cert) + (_user, email, fullname) = _ssl_dn_extract_info(cert) retfun = functools.partial(student_views.index, request) - return external_login_or_signup(request, - external_id=email, - external_domain="ssl:MIT", - credentials=cert, - email=email, - fullname=fullname, - retfun=retfun) + return _external_login_or_signup( + request, + external_id=email, + external_domain="ssl:MIT", + credentials=cert, + email=email, + fullname=fullname, + retfun=retfun + ) # ----------------------------------------------------------------------------- @@ -396,28 +403,30 @@ def shib_login(request): log.error("SHIB: no Shib-Identity-Provider in request.META") return default_render_failure(request, shib_error_msg) else: - #if we get here, the user has authenticated properly + # If we get here, the user has authenticated properly shib = {attr: request.META.get(attr, '') for attr in ['REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider']} - #Clean up first name, last name, and email address - #TODO: Make this less hardcoded re: format, but split will work - #even if ";" is not present since we are accessing 1st element + # Clean up first name, last name, and email address + # TODO: Make this less hardcoded re: format, but split will work + # even if ";" is not present, since we are accessing 1st element shib['sn'] = shib['sn'].split(";")[0].strip().capitalize().decode('utf-8') shib['givenName'] = shib['givenName'].split(";")[0].strip().capitalize().decode('utf-8') + # TODO: should we be logging creds here, at info level? log.info("SHIB creds returned: %r", shib) - return external_login_or_signup(request, - external_id=shib['REMOTE_USER'], - external_domain="shib:" + shib['Shib-Identity-Provider'], - credentials=shib, - email=shib['mail'], - fullname=u'%s %s' % (shib['givenName'], shib['sn']), - ) + return _external_login_or_signup( + request, + external_id=shib['REMOTE_USER'], + external_domain=SHIBBOLETH_DOMAIN_PREFIX + shib['Shib-Identity-Provider'], + credentials=shib, + email=shib['mail'], + fullname=u'%s %s' % (shib['givenName'], shib['sn']), + ) -def make_shib_enrollment_request(request): +def _make_shib_enrollment_request(request): """ Need this hack function because shibboleth logins don't happen over POST but change_enrollment expects its request to be a POST, with @@ -452,14 +461,14 @@ def course_specific_login(request, course_id): try: course = course_from_id(course_id) except ItemNotFoundError: - #couldn't find the course, will just return vanilla signin page + # couldn't find the course, will just return vanilla signin page return redirect_with_querystring('signin_user', query_string) - #now the dispatching conditionals. Only shib for now - if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain: + # now the dispatching conditionals. Only shib for now + if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX): return redirect_with_querystring('shib-login', query_string) - #Default fallthrough to normal signin page + # Default fallthrough to normal signin page return redirect_with_querystring('signin_user', query_string) @@ -473,15 +482,15 @@ def course_specific_register(request, course_id): try: course = course_from_id(course_id) except ItemNotFoundError: - #couldn't find the course, will just return vanilla registration page + # couldn't find the course, will just return vanilla registration page return redirect_with_querystring('register_user', query_string) - #now the dispatching conditionals. Only shib for now - if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and 'shib:' in course.enrollment_domain: - #shib-login takes care of both registration and login flows + # now the dispatching conditionals. Only shib for now + if settings.MITX_FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX): + # shib-login takes care of both registration and login flows return redirect_with_querystring('shib-login', query_string) - #Default fallthrough to normal registration page + # Default fallthrough to normal registration page return redirect_with_querystring('register_user', query_string) @@ -702,7 +711,7 @@ def provider_login(request): except User.DoesNotExist: request.session['openid_error'] = True msg = "OpenID login failed - Unknown user email: %s" - log.warning(msg, email) + AUDIT_LOG.warning(msg, email) return HttpResponseRedirect(openid_request_url) # attempt to authenticate user (but not actually log them in...) @@ -713,7 +722,7 @@ def provider_login(request): if user is None: request.session['openid_error'] = True msg = "OpenID login failed - password for %s is invalid" - log.warning(msg, email) + AUDIT_LOG.warning(msg, email) return HttpResponseRedirect(openid_request_url) # authentication succeeded, so fetch user information @@ -723,8 +732,8 @@ def provider_login(request): if 'openid_error' in request.session: del request.session['openid_error'] - log.info("OpenID login success - %s (%s)", - user.username, user.email) + AUDIT_LOG.info("OpenID login success - %s (%s)", + user.username, user.email) # redirect user to return_to location url = endpoint + urlquote(user.username) @@ -755,7 +764,7 @@ def provider_login(request): # the account is not active, so redirect back to the login page: request.session['openid_error'] = True msg = "Login failed - Account not active for user %s" - log.warning(msg, username) + AUDIT_LOG.warning(msg, username) return HttpResponseRedirect(openid_request_url) # determine consumer domain if applicable @@ -856,9 +865,11 @@ def test_center_login(request): try: testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) except TestCenterUser.DoesNotExist: - log.error("not able to find demographics for cand ID {}".format(client_candidate_id)) + AUDIT_LOG.error("not able to find demographics for cand ID {}".format(client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")) + AUDIT_LOG.info("Attempting to log in test-center user '{}' for test of cand {}".format(testcenteruser.user.username, client_candidate_id)) + # find testcenter_registration that matches the provided exam code: # Note that we could rely in future on either the registrationId or the exam code, # or possibly both. But for now we know what to do with an ExamSeriesCode, @@ -867,13 +878,13 @@ def test_center_login(request): # we are not allowed to make up a new error code, according to Pearson, # so instead of "missingExamSeriesCode", we use a valid one that is # inaccurate but at least distinct. (Sigh.) - log.error("missing exam series code for cand ID {}".format(client_candidate_id)) + AUDIT_LOG.error("missing exam series code for cand ID {}".format(client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")) exam_series_code = request.POST.get('vueExamSeriesCode') registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code) if not registrations: - log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) + AUDIT_LOG.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")) # TODO: figure out what to do if there are more than one registrations.... @@ -883,14 +894,14 @@ def test_center_login(request): course_id = registration.course_id course = course_from_id(course_id) # assume it will be found.... if not course: - log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) + AUDIT_LOG.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) exam = course.get_test_center_exam(exam_series_code) if not exam: - log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) + AUDIT_LOG.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) location = exam.exam_url - log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) + log.info("Proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) # check if the test has already been taken timelimit_descriptor = modulestore().get_instance(course_id, Location(location)) @@ -907,7 +918,7 @@ def test_center_login(request): return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) if timelimit_module and timelimit_module.has_ended: - log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) + AUDIT_LOG.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")) # check if we need to provide an accommodation: @@ -922,7 +933,7 @@ def test_center_login(request): if time_accommodation_code: timelimit_module.accommodation_code = time_accommodation_code - log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) + AUDIT_LOG.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) # UGLY HACK!!! # Login assumes that authentication has occurred, and that there is a @@ -936,6 +947,7 @@ def test_center_login(request): # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") login(request, testcenteruser.user) + AUDIT_LOG.info("Logged in user '{}' for test of cand {} on exam {} for course {}: URL = {}".format(testcenteruser.user.username, client_candidate_id, exam_series_code, course_id, location)) # And start the test: return jump_to(request, course_id, location) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 97d7e8b028..4c41427ca6 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -20,6 +20,7 @@ from random import randint from django.conf import settings from django.contrib.auth.models import User +from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver @@ -30,6 +31,7 @@ from pytz import UTC log = logging.getLogger(__name__) +AUDIT_LOG = logging.getLogger("audit") class UserProfile(models.Model): @@ -779,3 +781,20 @@ def update_user_information(sender, instance, created, **kwargs): log = logging.getLogger("mitx.discussion") log.error(unicode(e)) log.error("update user info to discussion failed for user with id: " + str(instance.id)) + +# Define login and logout handlers here in the models file, instead of the views file, +# so that they are more likely to be loaded when a Studio user brings up the Studio admin +# page to login. These are currently the only signals available, so we need to continue +# identifying and logging failures separately (in views). + + +@receiver(user_logged_in) +def log_successful_login(sender, request, user, **kwargs): + """Handler to log when logins have occurred successfully.""" + AUDIT_LOG.info(u"Login success - {0} ({1})".format(user.username, user.email)) + + +@receiver(user_logged_out) +def log_successful_logout(sender, request, user, **kwargs): + """Handler to log when logouts have occurred successfully.""" + AUDIT_LOG.info(u"Logout - {0}".format(request.user)) diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py new file mode 100644 index 0000000000..5a6cd043ae --- /dev/null +++ b/common/djangoapps/student/tests/test_login.py @@ -0,0 +1,143 @@ +''' +Tests for student activation and login +''' +import json +from mock import patch + +from django.test import TestCase +from django.test.client import Client +from django.core.urlresolvers import reverse, NoReverseMatch +from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory + + +class LoginTest(TestCase): + ''' + Test student.views.login_user() view + ''' + + def setUp(self): + # Create one user and save it to the database + self.user = UserFactory.build(username='test', email='test@edx.org') + self.user.set_password('test_password') + self.user.save() + + # Create a registration for the user + RegistrationFactory(user=self.user) + + # Create a profile for the user + UserProfileFactory(user=self.user) + + # Create the test client + self.client = Client() + + # Store the login url + try: + self.url = reverse('login_post') + except NoReverseMatch: + self.url = reverse('login') + + def test_login_success(self): + response, mock_audit_log = self._login_response('test@edx.org', 'test_password', patched_audit_log='student.models.AUDIT_LOG') + self._assert_response(response, success=True) + self._assert_audit_log(mock_audit_log, 'info', [u'Login success', u'test@edx.org']) + + def test_login_success_unicode_email(self): + unicode_email = u'test' + unichr(40960) + u'@edx.org' + self.user.email = unicode_email + self.user.save() + + response, mock_audit_log = self._login_response(unicode_email, 'test_password', patched_audit_log='student.models.AUDIT_LOG') + self._assert_response(response, success=True) + self._assert_audit_log(mock_audit_log, 'info', [u'Login success', unicode_email]) + + def test_login_fail_no_user_exists(self): + nonexistent_email = u'not_a_user@edx.org' + response, mock_audit_log = self._login_response(nonexistent_email, 'test_password') + self._assert_response(response, success=False, + value='Email or password is incorrect') + self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email]) + + def test_login_fail_wrong_password(self): + response, mock_audit_log = self._login_response('test@edx.org', 'wrong_password') + self._assert_response(response, success=False, + value='Email or password is incorrect') + self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid']) + + def test_login_not_activated(self): + # De-activate the user + self.user.is_active = False + self.user.save() + + # Should now be unable to login + response, mock_audit_log = self._login_response('test@edx.org', 'test_password') + self._assert_response(response, success=False, + value="This account has not been activated") + self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Account not active for user']) + + def test_login_unicode_email(self): + unicode_email = u'test@edx.org' + unichr(40960) + response, mock_audit_log = self._login_response(unicode_email, 'test_password') + self._assert_response(response, success=False) + self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', unicode_email]) + + def test_login_unicode_password(self): + unicode_password = u'test_password' + unichr(1972) + response, mock_audit_log = self._login_response('test@edx.org', unicode_password) + self._assert_response(response, success=False) + self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'password for', u'test@edx.org', u'invalid']) + + def test_logout_logging(self): + response, _ = self._login_response('test@edx.org', 'test_password') + self._assert_response(response, success=True) + logout_url = reverse('logout') + with patch('student.models.AUDIT_LOG') as mock_audit_log: + response = self.client.post(logout_url) + self.assertEqual(response.status_code, 302) + self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test']) + + def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG'): + ''' Post the login info ''' + post_params = {'email': email, 'password': password} + with patch(patched_audit_log) as mock_audit_log: + result = self.client.post(self.url, post_params) + return result, mock_audit_log + + def _assert_response(self, response, success=None, value=None): + ''' + Assert that the response had status 200 and returned a valid + JSON-parseable dict. + + If success is provided, assert that the response had that + value for 'success' in the JSON dict. + + If value is provided, assert that the response contained that + value for 'value' in the JSON dict. + ''' + self.assertEqual(response.status_code, 200) + + try: + response_dict = json.loads(response.content) + except ValueError: + self.fail("Could not parse response content as JSON: %s" + % str(response.content)) + + if success is not None: + self.assertEqual(response_dict['success'], success) + + if value is not None: + msg = ("'%s' did not contain '%s'" % + (str(response_dict['value']), str(value))) + self.assertTrue(value in response_dict['value'], msg) + + def _assert_audit_log(self, mock_audit_log, level, log_strings): + """ + Check that the audit log has received the expected call. + """ + method_calls = mock_audit_log.method_calls + self.assertEquals(len(method_calls), 1) + name, args, _kwargs = method_calls[0] + self.assertEquals(name, level) + self.assertEquals(len(args), 1) + format_string = args[0] + for log_string in log_strings: + self.assertIn(log_string, format_string) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 74b465f690..66b7148c99 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -56,6 +56,8 @@ from statsd import statsd from pytz import UTC log = logging.getLogger("mitx.student") +AUDIT_LOG = logging.getLogger("audit") + Article = namedtuple('Article', 'title url author image deck publication publish_date') @@ -107,8 +109,7 @@ day_pattern = re.compile(r'\s\d+,\s') multimonth_pattern = re.compile(r'\s?\-\s?\S+\s') -def get_date_for_press(publish_date): - import datetime +def _get_date_for_press(publish_date): # strip off extra months, and just use the first: date = re.sub(multimonth_pattern, ", ", publish_date) if re.search(day_pattern, date): @@ -129,7 +130,7 @@ def press(request): json_articles = json.loads(content) cache.set("student_press_json_articles", json_articles) articles = [Article(**article) for article in json_articles] - articles.sort(key=lambda item: get_date_for_press(item.publish_date), reverse=True) + articles.sort(key=lambda item: _get_date_for_press(item.publish_date), reverse=True) return render_to_response('static_templates/press.html', {'articles': articles}) @@ -233,7 +234,7 @@ def signin_user(request): @ensure_csrf_cookie -def register_user(request, extra_context={}): +def register_user(request, extra_context=None): """ This view will display the non-modal registration form """ @@ -244,7 +245,8 @@ def register_user(request, extra_context={}): 'course_id': request.GET.get('course_id'), 'enrollment_action': request.GET.get('enrollment_action') } - context.update(extra_context) + if extra_context is not None: + context.update(extra_context) return render_to_response('register.html', context) @@ -381,7 +383,7 @@ def change_enrollment(request): "run:{0}".format(run)]) try: - enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) + enrollment, _created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) except IntegrityError: # If we've already created this enrollment in a separate transaction, # then just continue @@ -425,19 +427,21 @@ def login_user(request, error=""): try: user = User.objects.get(email=email) except User.DoesNotExist: - log.warning(u"Login failed - Unknown user email: {0}".format(email)) + AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) return HttpResponse(json.dumps({'success': False, 'value': _('Email or password is incorrect.')})) # TODO: User error message username = user.username user = authenticate(username=username, password=password) if user is None: - log.warning(u"Login failed - password for {0} is invalid".format(email)) + AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email)) return HttpResponse(json.dumps({'success': False, 'value': _('Email or password is incorrect.')})) if user is not None and user.is_active: try: + # We do not log here, because we have a handler registered + # to perform logging on successful logins. login(request, user) if request.POST.get('remember') == 'true': request.session.set_expiry(604800) @@ -445,14 +449,14 @@ def login_user(request, error=""): else: request.session.set_expiry(0) except Exception as e: + AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?") log.critical("Login failed - Could not create session. Is memcached running?") log.exception(e) - - log.info(u"Login success - {0} ({1})".format(username, email)) + raise try_change_enrollment(request) - statsd.increment(_("common.student.successful_login")) + statsd.increment("common.student.successful_login") response = HttpResponse(json.dumps({'success': True})) # set the login cookie for the edx marketing site @@ -476,7 +480,7 @@ def login_user(request, error=""): return response - log.warning(u"Login failed - Account not active for user {0}, resending activation".format(username)) + AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(username)) reactivation_email_for_user(user) not_activated_msg = _("This account has not been activated. We have sent another activation message. Please check your e-mail for the activation instructions.") @@ -491,7 +495,8 @@ def logout_user(request): Deletes both the CSRF and sessionid cookies so the marketing site can determine the logged in state of the user ''' - + # We do not log here, because we have a handler registered + # to perform logging on successful logouts. logout(request) response = redirect('/') response.delete_cookie(settings.EDXMKTG_COOKIE_NAME, @@ -598,7 +603,7 @@ def create_account(request, post_override=None): password = eamap.internal_password post_vars = dict(post_vars.items()) post_vars.update(dict(email=email, name=name, password=password)) - log.info('In create_account with external_auth: post_vars = %s' % post_vars) + log.debug(u'In create_account with external_auth: user = %s, email=%s', name, email) # Confirm we have a properly formed request for a in ['username', 'email', 'password', 'name']: @@ -631,7 +636,7 @@ def create_account(request, post_override=None): required_post_vars = ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code'] if tos_not_required: - required_post_vars = ['username', 'email', 'name', 'password', 'honor_code'] + required_post_vars = ['username', 'email', 'name', 'password', 'honor_code'] for a in required_post_vars: if len(post_vars[a]) < 2: @@ -684,7 +689,7 @@ def create_account(request, post_override=None): '-' * 80 + '\n\n' + message) send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [dest_addr], fail_silently=False) else: - res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + _res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) except: log.warning('Unable to send activation email to user', exc_info=True) js['value'] = _('Could not send activation e-mail.') @@ -697,17 +702,23 @@ def create_account(request, post_override=None): login(request, login_user) request.session.set_expiry(0) + # TODO: there is no error checking here to see that the user actually logged in successfully, + # and is not yet an active user. + if login_user is not None: + AUDIT_LOG.info(u"Login success on new account creation - {0}".format(login_user.username)) + if DoExternalAuth: eamap.user = login_user eamap.dtsignup = datetime.datetime.now(UTC) eamap.save() - log.info("User registered with external_auth %s" % post_vars['username']) - log.info('Updated ExternalAuthMap for %s to be %s' % (post_vars['username'], eamap)) + AUDIT_LOG.info("User registered with external_auth %s", post_vars['username']) + AUDIT_LOG.info('Updated ExternalAuthMap for %s to be %s', post_vars['username'], eamap) if settings.MITX_FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): log.info('bypassing activation email') login_user.is_active = True login_user.save() + AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(login_user.username, login_user.email)) try_change_enrollment(request) @@ -964,14 +975,14 @@ def activate_account(request, key): r[0].activate() already_active = False - #Enroll student in any pending courses he/she may have if auto_enroll flag is set + # Enroll student in any pending courses he/she may have if auto_enroll flag is set student = User.objects.filter(id=r[0].user_id) if student: ceas = CourseEnrollmentAllowed.objects.filter(email=student[0].email) for cea in ceas: if cea.auto_enroll: course_id = cea.course_id - enrollment, created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id) + _enrollment, _created = CourseEnrollment.objects.get_or_create(user_id=student[0].id, course_id=course_id) resp = render_to_response("registration/activation_complete.html", {'user_logged_in': user_logged_in, 'already_active': already_active}) return resp @@ -1003,7 +1014,7 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None): ''' A wrapper around django.contrib.auth.views.password_reset_confirm. Needed because we want to set the user as active at this step. ''' - #cribbed from django.contrib.auth.views.password_reset_confirm + # cribbed from django.contrib.auth.views.password_reset_confirm try: uid_int = base36_to_int(uidb36) user = User.objects.get(id=uid_int) @@ -1029,7 +1040,7 @@ def reactivation_email_for_user(user): message = render_to_string('emails/activation_email.txt', d) try: - res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) + _res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL) except: log.warning('Unable to send reactivation email', exc_info=True) return HttpResponse(json.dumps({'success': False, 'error': _('Unable to send reactivation email')})) @@ -1087,7 +1098,7 @@ def change_email_request(request): subject = ''.join(subject.splitlines()) message = render_to_string('emails/email_change.txt', d) - res = send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [pec.new_email]) + _res = send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [pec.new_email]) return HttpResponse(json.dumps({'success': True})) diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index dd57e8d4d4..a90707abdc 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -24,7 +24,7 @@ def index(request): from external_auth.views import ssl_login return ssl_login(request) if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE'): - return redirect(settings.MKTG_URLS.get('ROOT')) + return redirect(settings.MKTG_URLS.get('ROOT')) university = branding.get_university(request.META.get('HTTP_HOST')) if university is None: diff --git a/lms/djangoapps/courseware/tests/test_login.py b/lms/djangoapps/courseware/tests/test_login.py deleted file mode 100644 index 9f1cd23b27..0000000000 --- a/lms/djangoapps/courseware/tests/test_login.py +++ /dev/null @@ -1,107 +0,0 @@ -''' -Tests for student activation and login -''' -from django.test import TestCase -from django.test.client import Client -from django.core.urlresolvers import reverse -from courseware.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory -import json - - -class LoginTest(TestCase): - ''' - Test student.views.login_user() view - ''' - - def setUp(self): - # Create one user and save it to the database - self.user = UserFactory.build(username='test', email='test@edx.org') - self.user.set_password('test_password') - self.user.save() - - # Create a registration for the user - RegistrationFactory(user=self.user) - - # Create a profile for the user - UserProfileFactory(user=self.user) - - # Create the test client - self.client = Client() - - # Store the login url - self.url = reverse('login') - - def test_login_success(self): - response = self._login_response('test@edx.org', 'test_password') - self._assert_response(response, success=True) - - def test_login_success_unicode_email(self): - unicode_email = u'test@edx.org' + unichr(40960) - - self.user.email = unicode_email - self.user.save() - - response = self._login_response(unicode_email, 'test_password') - self._assert_response(response, success=True) - - def test_login_fail_no_user_exists(self): - response = self._login_response('not_a_user@edx.org', 'test_password') - self._assert_response(response, success=False, - value='Email or password is incorrect') - - def test_login_fail_wrong_password(self): - response = self._login_response('test@edx.org', 'wrong_password') - self._assert_response(response, success=False, - value='Email or password is incorrect') - - def test_login_not_activated(self): - # De-activate the user - self.user.is_active = False - self.user.save() - - # Should now be unable to login - response = self._login_response('test@edx.org', 'test_password') - self._assert_response(response, success=False, - value="This account has not been activated") - - def test_login_unicode_email(self): - unicode_email = u'test@edx.org' + unichr(40960) - response = self._login_response(unicode_email, 'test_password') - self._assert_response(response, success=False) - - def test_login_unicode_password(self): - unicode_password = u'test_password' + unichr(1972) - response = self._login_response('test@edx.org', unicode_password) - self._assert_response(response, success=False) - - def _login_response(self, email, password): - ''' Post the login info ''' - post_params = {'email': email, 'password': password} - return self.client.post(self.url, post_params) - - def _assert_response(self, response, success=None, value=None): - ''' - Assert that the response had status 200 and returned a valid - JSON-parseable dict. - - If success is provided, assert that the response had that - value for 'success' in the JSON dict. - - If value is provided, assert that the response contained that - value for 'value' in the JSON dict. - ''' - self.assertEqual(response.status_code, 200) - - try: - response_dict = json.loads(response.content) - except ValueError: - self.fail("Could not parse response content as JSON: %s" - % str(response.content)) - - if success is not None: - self.assertEqual(response_dict['success'], success) - - if value is not None: - msg = ("'%s' did not contain '%s'" % - (str(response_dict['value']), str(value))) - self.assertTrue(value in response_dict['value'], msg) From b6777118c6668dc3f674afe330031a597f83865f Mon Sep 17 00:00:00 2001 From: Frances Botsford <frances@edx.org> Date: Wed, 31 Jul 2013 15:25:38 -0400 Subject: [PATCH 282/556] removing animate class from course creation for now --- cms/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/index.html b/cms/templates/index.html index 59f54fe6ab..9c845ccb5a 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -80,7 +80,7 @@ </div> % if course_creator_status=='granted': - <div class="wrapper-create-element animate wrapper-create-course"> + <div class="wrapper-create-element wrapper-create-course"> <form class="create-course course-info" id="create-course-form" name="create-course-form"> <div class="wrap-error"> <div id="course_creation_error" name="course_creation_error" class="message message-status message-status error" role="alert"> From 248793c1270e6c089c65da1c49b714701978ffea Mon Sep 17 00:00:00 2001 From: David Baumgold <david@davidbaumgold.com> Date: Wed, 31 Jul 2013 15:42:11 -0400 Subject: [PATCH 283/556] Fix some pylint issues --- .../contentstore/management/commands/clone.py | 18 +++++----- .../commands/dump_course_structure.py | 9 ++++- .../management/commands/export.py | 15 +++++---- .../management/commands/export_all_courses.py | 27 +++++++-------- .../management/commands/import.py | 17 +++++----- .../contentstore/management/commands/xlint.py | 19 +++++------ .../contentstore/tests/test_assets.py | 6 ++-- .../contentstore/tests/test_checklists.py | 1 + .../contentstore/tests/test_contentstore.py | 3 +- .../tests/test_course_settings.py | 6 ++-- .../contentstore/tests/test_i18n.py | 8 +++-- .../contentstore/tests/test_item.py | 33 +++++++++++-------- cms/djangoapps/contentstore/tests/tests.py | 26 +++++++++------ cms/djangoapps/contentstore/views/assets.py | 2 +- .../contentstore/views/component.py | 5 +++ cms/djangoapps/contentstore/views/preview.py | 2 ++ cms/djangoapps/contentstore/views/public.py | 8 ++--- cms/djangoapps/contentstore/views/tabs.py | 6 ++++ pylintrc | 2 +- 19 files changed, 123 insertions(+), 90 deletions(-) diff --git a/cms/djangoapps/contentstore/management/commands/clone.py b/cms/djangoapps/contentstore/management/commands/clone.py index 0ca50acb50..f20625d7f2 100644 --- a/cms/djangoapps/contentstore/management/commands/clone.py +++ b/cms/djangoapps/contentstore/management/commands/clone.py @@ -1,6 +1,6 @@ -### -### Script for cloning a course -### +""" +Script for cloning a course +""" from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.django import modulestore @@ -15,23 +15,25 @@ from auth.authz import _copy_course_group class Command(BaseCommand): + """Clone a MongoDB-backed course to another location""" help = 'Clone a MongoDB backed course to another location' def handle(self, *args, **options): + "Execute the command" if len(args) != 2: raise CommandError("clone requires two arguments: <source-location> <dest-location>") source_location_str = args[0] dest_location_str = args[1] - ms = modulestore('direct') - cs = contentstore() + mstore = modulestore('direct') + cstore = contentstore() - print "Cloning course {0} to {1}".format(source_location_str, dest_location_str) + print("Cloning course {0} to {1}".format(source_location_str, dest_location_str)) source_location = CourseDescriptor.id_to_location(source_location_str) dest_location = CourseDescriptor.id_to_location(dest_location_str) - if clone_course(ms, cs, source_location, dest_location): - print "copying User permissions..." + if clone_course(mstore, cstore, source_location, dest_location): + print("copying User permissions...") _copy_course_group(source_location, dest_location) diff --git a/cms/djangoapps/contentstore/management/commands/dump_course_structure.py b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py index d9b7c55cbd..139c603172 100644 --- a/cms/djangoapps/contentstore/management/commands/dump_course_structure.py +++ b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py @@ -1,3 +1,6 @@ +""" +Script for dumping course dumping the course structure +""" from django.core.management.base import BaseCommand, CommandError from xmodule.course_module import CourseDescriptor from xmodule.modulestore.django import modulestore @@ -9,10 +12,14 @@ filter_list = ['xml_attributes', 'checklists'] class Command(BaseCommand): + """ + The Django command for dumping course structure + """ help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized in a JSON format. This can be used for analytics.''' def handle(self, *args, **options): + "Execute the command" if len(args) < 2 or len(args) > 3: raise CommandError("dump_course_structure requires two or more arguments: <location> <outfile> |<db>|") @@ -32,7 +39,7 @@ class Command(BaseCommand): try: course = store.get_item(loc, depth=4) except: - print 'Could not find course at {0}'.format(course_id) + print('Could not find course at {0}'.format(course_id)) return info = {} diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index 90db8750d9..efeb5dc339 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -1,6 +1,6 @@ -### -### Script for exporting courseware from Mongo to a tar.gz file -### +""" +Script for exporting courseware from Mongo to a tar.gz file +""" import os from django.core.management.base import BaseCommand, CommandError @@ -10,20 +10,21 @@ from xmodule.contentstore.django import contentstore from xmodule.course_module import CourseDescriptor -unnamed_modules = 0 - - class Command(BaseCommand): + """ + Export the specified data directory into the default ModuleStore + """ help = 'Export the specified data directory into the default ModuleStore' def handle(self, *args, **options): + "Execute the command" if len(args) != 2: raise CommandError("export requires two arguments: <course location> <output path>") course_id = args[0] output_path = args[1] - print "Exporting course id = {0} to {1}".format(course_id, output_path) + print("Exporting course id = {0} to {1}".format(course_id, output_path)) location = CourseDescriptor.id_to_location(course_id) diff --git a/cms/djangoapps/contentstore/management/commands/export_all_courses.py b/cms/djangoapps/contentstore/management/commands/export_all_courses.py index 69cfb298fb..2118551138 100644 --- a/cms/djangoapps/contentstore/management/commands/export_all_courses.py +++ b/cms/djangoapps/contentstore/management/commands/export_all_courses.py @@ -1,8 +1,6 @@ -### -### Script for exporting all courseware from Mongo to a directory -### -import os - +""" +Script for exporting all courseware from Mongo to a directory +""" from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.django import modulestore @@ -10,13 +8,12 @@ from xmodule.contentstore.django import contentstore from xmodule.course_module import CourseDescriptor -unnamed_modules = 0 - - class Command(BaseCommand): + """Export all courses from mongo to the specified data directory""" help = 'Export all courses from mongo to the specified data directory' def handle(self, *args, **options): + "Execute the command" if len(args) != 1: raise CommandError("export requires one argument: <output path>") @@ -27,14 +24,14 @@ class Command(BaseCommand): root_dir = output_path courses = ms.get_courses() - print "%d courses to export:" % len(courses) + print("%d courses to export:" % len(courses)) cids = [x.id for x in courses] - print cids + print(cids) for course_id in cids: - print "-"*77 - print "Exporting course id = {0} to {1}".format(course_id, output_path) + print("-"*77) + print("Exporting course id = {0} to {1}".format(course_id, output_path)) if 1: try: @@ -42,6 +39,6 @@ class Command(BaseCommand): course_dir = course_id.replace('/', '...') export_to_xml(ms, cs, location, root_dir, course_dir, modulestore()) except Exception as err: - print "="*30 + "> Oops, failed to export %s" % course_id - print "Error:" - print err + print("="*30 + "> Oops, failed to export %s" % course_id) + print("Error:") + print(err) diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 9b919daad0..46f439b055 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -1,6 +1,6 @@ -### -### Script for importing courseware from XML format -### +""" +Script for importing courseware from XML format +""" from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_importer import import_from_xml @@ -8,13 +8,14 @@ from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore -unnamed_modules = 0 - - class Command(BaseCommand): + """ + Import the specified data directory into the default ModuleStore + """ help = 'Import the specified data directory into the default ModuleStore' def handle(self, *args, **options): + "Execute the command" if len(args) == 0: raise CommandError("import requires at least one argument: <data directory> [<course dir>...]") @@ -23,8 +24,8 @@ class Command(BaseCommand): course_dirs = args[1:] else: course_dirs = None - print "Importing. Data_dir={data}, course_dirs={courses}".format( + print("Importing. Data_dir={data}, course_dirs={courses}".format( data=data_dir, - courses=course_dirs) + courses=course_dirs)) import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False, static_content_store=contentstore(), verbose=True) diff --git a/cms/djangoapps/contentstore/management/commands/xlint.py b/cms/djangoapps/contentstore/management/commands/xlint.py index 21c8e7d1f8..835b8b84df 100644 --- a/cms/djangoapps/contentstore/management/commands/xlint.py +++ b/cms/djangoapps/contentstore/management/commands/xlint.py @@ -1,18 +1,17 @@ +""" +Verify the structure of courseware as to it's suitability for import +To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)] +""" from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_importer import perform_xlint -unnamed_modules = 0 - - class Command(BaseCommand): - help = \ - ''' - Verify the structure of courseware as to it's suitability for import - To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)] - ''' + """Verify the structure of courseware as to it's suitability for import""" + help = "Verify the structure of courseware as to it's suitability for import" def handle(self, *args, **options): + "Execute the command" if len(args) == 0: raise CommandError("import requires at least one argument: <data directory> [<course dir>...]") @@ -21,7 +20,7 @@ class Command(BaseCommand): course_dirs = args[1:] else: course_dirs = None - print "Importing. Data_dir={data}, course_dirs={courses}".format( + print("Importing. Data_dir={data}, course_dirs={courses}".format( data=data_dir, - courses=course_dirs) + courses=course_dirs)) perform_xlint(data_dir, course_dirs, load_error_modules=False) diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index 58aee3c77d..cde40d502e 100644 --- a/cms/djangoapps/contentstore/tests/test_assets.py +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -50,9 +50,9 @@ class UploadTestCase(CourseTestCase): @skip("CorruptGridFile error on continuous integration server") def test_happy_path(self): - file = BytesIO("sample content") - file.name = "sample.txt" - resp = self.client.post(self.url, {"name": "my-name", "file": file}) + f = BytesIO("sample content") + f.name = "sample.txt" + resp = self.client.post(self.url, {"name": "my-name", "file": f}) self.assert2XX(resp.status_code) def test_no_file(self): diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 6f8f102df8..5a99c37fbb 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -27,6 +27,7 @@ class ChecklistTestCase(CourseTestCase): """ self.assertEqual(persisted['short_description'], request['short_description']) compare_urls = (persisted.get('action_urls_expanded') == request.get('action_urls_expanded')) + pers, req = None, None for pers, req in zip(persisted['items'], request['items']): self.assertEqual(pers['short_description'], req['short_description']) self.assertEqual(pers['long_description'], req['long_description']) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index b667c49c36..cedc063fac 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -95,8 +95,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) def tearDown(self): - mongo = MongoClient() - mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) _CONTENTSTORE.clear() def check_components_on_page(self, component_types, expected_types): diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 0862eb462d..2007ba2f69 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -18,8 +18,6 @@ from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata -from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.django import modulestore from xmodule.fields import Date from .utils import CourseTestCase @@ -167,8 +165,8 @@ class CourseDetailsViewTest(CourseTestCase): self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val)) @staticmethod - def convert_datetime_to_iso(dt): - return Date().to_json(dt) + def convert_datetime_to_iso(datetime_obj): + return Date().to_json(datetime_obj) def test_update_and_fetch(self): loc = self.course.location diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index 88df19ec2d..e6baf57213 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -85,9 +85,11 @@ class InternationalizationTest(ModuleStoreTestCase): HTTP_ACCEPT_LANGUAGE='fr' ) - TEST_STRING = u'<h1 class="title-1">' \ - + u'My \xc7\xf6\xfcrs\xe9s L#' \ - + u'</h1>' + TEST_STRING = ( + u'<h1 class="title-1">' + u'My \xc7\xf6\xfcrs\xe9s L#' + u'</h1>' + ) self.assertContains(resp, TEST_STRING, diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 578b82b3cf..827dd1b054 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -14,19 +14,26 @@ class DeleteItem(CourseTestCase): super(DeleteItem, self).setUp() self.course = CourseFactory.create(org='mitX', number='333', display_name='Dummy Course') - def testDeleteStaticPage(self): + def test_delete_static_page(self): # Add static tab data = json.dumps({ 'parent_location': 'i4x://mitX/333/course/Dummy_Course', 'category': 'static_tab' }) - resp = self.client.post(reverse('create_item'), data, - content_type="application/json") + resp = self.client.post( + reverse('create_item'), + data, + content_type="application/json" + ) self.assertEqual(resp.status_code, 200) # Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore). - resp = self.client.post(reverse('delete_item'), resp.content, "application/json") + resp = self.client.post( + reverse('delete_item'), + resp.content, + "application/json" + ) self.assertEqual(resp.status_code, 200) @@ -122,6 +129,7 @@ class TestCreateItem(CourseTestCase): ) self.assertEqual(resp.status_code, 200) + class TestEditItem(CourseTestCase): """ Test contentstore.views.item.save_item @@ -151,10 +159,10 @@ class TestEditItem(CourseTestCase): chap_location = self.response_id(resp) resp = self.client.post( reverse('create_item'), - json.dumps( - {'parent_location': chap_location, - 'category': 'sequential' - }), + json.dumps({ + 'parent_location': chap_location, + 'category': 'sequential', + }), content_type="application/json" ) self.seq_location = self.response_id(resp) @@ -162,9 +170,10 @@ class TestEditItem(CourseTestCase): template_id = 'multiplechoice.yaml' resp = self.client.post( reverse('create_item'), - json.dumps({'parent_location': self.seq_location, - 'category': 'problem', - 'boilerplate': template_id + json.dumps({ + 'parent_location': self.seq_location, + 'category': 'problem', + 'boilerplate': template_id, }), content_type="application/json" ) @@ -195,7 +204,6 @@ class TestEditItem(CourseTestCase): problem = modulestore('draft').get_item(self.problems[0]) self.assertEqual(problem.rerandomize, 'never') - def test_null_field(self): """ Sending null in for a field 'deletes' it @@ -240,4 +248,3 @@ class TestEditItem(CourseTestCase): sequential = modulestore().get_item(self.seq_location) self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC)) self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC)) - diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index d55a7eff55..1f2a4185a3 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -15,14 +15,16 @@ class ContentStoreTestCase(ModuleStoreTestCase): Login. View should always return 200. The success/fail is in the returned json """ - resp = self.client.post(reverse('login_post'), - {'email': email, 'password': password}) + resp = self.client.post( + reverse('login_post'), + {'email': email, 'password': password} + ) self.assertEqual(resp.status_code, 200) return resp - def login(self, email, pw): + def login(self, email, password): """Login, check that it worked.""" - resp = self._login(email, pw) + resp = self._login(email, password) data = parse_json(resp) self.assertTrue(data['success']) return resp @@ -178,11 +180,15 @@ class ForumTestCase(CourseTestCase): def test_blackouts(self): now = datetime.datetime.now(UTC) - self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in - [(now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)), - (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + times1 = [ + (now - datetime.timedelta(days=14), now - datetime.timedelta(days=11)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30)) + ] + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in times1] self.assertTrue(self.course.forum_posts_allowed) - self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in - [(now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), - (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30))]] + times2 = [ + (now - datetime.timedelta(days=14), now + datetime.timedelta(days=2)), + (now + datetime.timedelta(days=24), now + datetime.timedelta(days=30)) + ] + self.course.discussion_blackouts = [(t.isoformat(), t2.isoformat()) for t, t2 in times2] self.assertFalse(self.course.forum_posts_allowed) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 6d371bef18..e4201cddd7 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -283,7 +283,7 @@ def import_course(request, org, course, name): tar_file.extractall(course_dir + '/') # find the 'course.xml' file - + dirpath = None for dirpath, _dirnames, filenames in os.walk(course_dir): for filename in filenames: if filename == 'course.xml': diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 8b8c289da3..7cb503db1e 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -58,6 +58,7 @@ ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' @login_required def edit_subsection(request, location): + "Edit the subsection of a course" # check that we have permissions to edit this item try: course = get_course_for_item(location) @@ -269,6 +270,7 @@ def assignment_type_update(request, org, course, category, name): @login_required @expect_json def create_draft(request): + "Create a draft" location = request.POST['id'] # check permissions for this user within this course @@ -285,6 +287,7 @@ def create_draft(request): @login_required @expect_json def publish_draft(request): + "Publish a draft" location = request.POST['id'] # check permissions for this user within this course @@ -300,6 +303,7 @@ def publish_draft(request): @login_required @expect_json def unpublish_unit(request): + "Unpublish a unit" location = request.POST['id'] # check permissions for this user within this course @@ -317,6 +321,7 @@ def unpublish_unit(request): @login_required @ensure_csrf_cookie def module_info(request, module_location): + "Get or set information for a module in the modulestore" location = Location(module_location) # check that logged in user has permissions to this item diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index d3a5dd1c8d..f2a07abe32 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -68,6 +68,7 @@ def preview_dispatch(request, preview_id, location, dispatch=None): @login_required def preview_component(request, location): + "Return the HTML preview of a component" # TODO (vshnayder): change name from id to location in coffee+html as well. if not has_access(request.user, location): return HttpResponseForbidden() @@ -91,6 +92,7 @@ def preview_module_system(request, preview_id, descriptor): """ def preview_model_data(descriptor): + "Helper method to create a DbModel from a descriptor" return DbModel( SessionKeyValueStore(request, descriptor._model_data), descriptor.module_class, diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 0ee228b996..2f74df1d8c 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -1,3 +1,6 @@ +""" +Public views +""" from django_future.csrf import ensure_csrf_cookie from django.core.context_processors import csrf from django.shortcuts import redirect @@ -10,10 +13,6 @@ from .user import index __all__ = ['signup', 'old_login_redirect', 'login_page', 'howitworks'] -""" -Public views -""" - @ensure_csrf_cookie def signup(request): @@ -45,6 +44,7 @@ def login_page(request): def howitworks(request): + "Proxy view" if request.user.is_authenticated(): return index(request) else: diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index d55932e33d..f38685edfc 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -1,3 +1,6 @@ +""" +Views related to course tabs +""" from access import has_access from util.json_request import expect_json @@ -39,6 +42,7 @@ def initialize_course_tabs(course): @login_required @expect_json def reorder_static_tabs(request): + "Order the static tabs in the requested order" tabs = request.POST['tabs'] course = get_course_for_item(tabs[0]) @@ -86,6 +90,7 @@ def reorder_static_tabs(request): @login_required @ensure_csrf_cookie def edit_tabs(request, org, course, coursename): + "Edit tabs" location = ['i4x', org, course, 'course', coursename] store = get_modulestore(location) course_item = store.get_item(location) @@ -122,6 +127,7 @@ def edit_tabs(request, org, course, coursename): @login_required @ensure_csrf_cookie def static_pages(request, org, course, coursename): + "Static pages view" location = get_location_and_verify_access(request, org, course, coursename) diff --git a/pylintrc b/pylintrc index e501cf6156..9525f04362 100644 --- a/pylintrc +++ b/pylintrc @@ -160,7 +160,7 @@ variable-rgx=[a-z_][a-z0-9_]{2,30}$ inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ # Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ +good-names=f,i,j,k,ex,Run,_,__ # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata From 02463398195b2cc99adc13ef867df49c2e7e8e0e Mon Sep 17 00:00:00 2001 From: Sef Kloninger <sef@kloninger.com> Date: Wed, 31 Jul 2013 13:20:29 -0700 Subject: [PATCH 284/556] fix crash in peer eval xmodule As documented in Jira LMS-806: Steps to reproduce: * as one user, for a peer evaluated problem, enter something that needs peer grading * as another user, log in and fire up the peer grading panel. You should see something that needs attention, a yellow exclamation badge * clicking on the "do grading" panel will fail with a 500, stacktrace attached. * it's possible that the question where this is being done must first have professor calibration done in order for peers to be able to actually get work to grade. Don Mitchell debugged this with me and saw some problems in the ways that the peer_evaluation xmodule code was accessing dates. Instead of using the normal lms accessors, by going straight into the _module_data it would be bypassing some important code (caching?). Fixing this to be more standard did the trick. --- .../xmodule/xmodule/peer_grading_module.py | 19 +++++++++---------- common/lib/xmodule/xmodule/timeinfo.py | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 09cac9a6b4..05883ce8c0 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -12,7 +12,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from .timeinfo import TimeInfo from xblock.core import Dict, String, Scope, Boolean, Integer, Float -from xmodule.fields import Date +from xmodule.fields import Date, Timedelta from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService from open_ended_grading_classes import combined_open_ended_rubric @@ -47,9 +47,8 @@ class PeerGradingFields(object): help="Due date that should be displayed.", default=None, scope=Scope.settings) - grace_period_string = String( + graceperiod = Timedelta( help="Amount of grace to give on the due date.", - default=None, scope=Scope.settings ) student_data_for_location = Dict( @@ -105,12 +104,12 @@ class PeerGradingModule(PeerGradingFields, XModule): log.error("Linked location {0} for peer grading module {1} does not exist".format( self.link_to_location, self.location)) raise - due_date = self.linked_problem._model_data.get('due', None) + due_date = self.linked_problem.lms.due if due_date: - self._model_data['due'] = due_date + self.lms.due = due_date try: - self.timeinfo = TimeInfo(self.due, self.grace_period_string) + self.timeinfo = TimeInfo(self.due, self.graceperiod) except Exception: log.error("Error parsing due date information in location {0}".format(self.location)) raise @@ -533,10 +532,10 @@ class PeerGradingModule(PeerGradingFields, XModule): problem_location = problem['location'] descriptor = _find_corresponding_module_for_location(problem_location) if descriptor: - problem['due'] = descriptor._model_data.get('due', None) - grace_period_string = descriptor._model_data.get('graceperiod', None) + problem['due'] = descriptor.lms.due + grace_period = descriptor.lms.graceperiod try: - problem_timeinfo = TimeInfo(problem['due'], grace_period_string) + problem_timeinfo = TimeInfo(problem['due'], grace_period) except: log.error("Malformed due date or grace period string for location {0}".format(problem_location)) raise @@ -629,5 +628,5 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor): @property def non_editable_metadata_fields(self): non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields - non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.grace_period_string]) + non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.graceperiod]) return non_editable_fields diff --git a/common/lib/xmodule/xmodule/timeinfo.py b/common/lib/xmodule/xmodule/timeinfo.py index 8f4d99506a..76f24a0b23 100644 --- a/common/lib/xmodule/xmodule/timeinfo.py +++ b/common/lib/xmodule/xmodule/timeinfo.py @@ -14,20 +14,23 @@ class TimeInfo(object): """ _delta_standin = Timedelta() - def __init__(self, due_date, grace_period_string): + def __init__(self, due_date, grace_period_string_or_timedelta): if due_date is not None: self.display_due_date = due_date else: self.display_due_date = None - if grace_period_string is not None and self.display_due_date: - try: - self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string) - self.close_date = self.display_due_date + self.grace_period - except: - log.error("Error parsing the grace period {0}".format(grace_period_string)) - raise + if grace_period_string_or_timedelta is not None and self.display_due_date: + if isinstance(grace_period_string_or_timedelta, basestring): + try: + self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string_or_timedelta) + except: + log.error("Error parsing the grace period {0}".format(grace_period_string_or_timedelta)) + raise + else: + self.grace_period = grace_period_string_or_timedelta + self.close_date = self.display_due_date + self.grace_period else: self.grace_period = None self.close_date = self.display_due_date From 8abd7bb6fe1c28ec92b96f9682f7d7a7848fca2e Mon Sep 17 00:00:00 2001 From: e0d <edward@indeterminate.org> Date: Wed, 31 Jul 2013 16:40:54 -0400 Subject: [PATCH 285/556] these values are set implicitly at :147, setting them here clobbers them --- lms/envs/aws.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index e039219be8..8d2ffba96e 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -178,10 +178,6 @@ for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items(): COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) -# automatic log in for load testing -MITX_FEATURES['AUTOMATIC_AUTH_FOR_LOAD_TESTING'] = ENV_TOKENS.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING') -MITX_FEATURES['MAX_AUTO_AUTH_USERS'] = ENV_TOKENS.get('MAX_AUTO_AUTH_USERS') - ############################## SECURE AUTH ITEMS ############### # Secret things: passwords, access keys, etc. From 64ad5567f350e770d7bb0d6c91dc4a231a01583e Mon Sep 17 00:00:00 2001 From: David Baumgold <david@davidbaumgold.com> Date: Wed, 31 Jul 2013 17:20:24 -0400 Subject: [PATCH 286/556] Create urls/views/templates for dev-only views Our designers find it helpful to be able to stub out simple views that aren't ready to be seen for production yet, and check them into version control so that other people can see them and provide feedback. This commit introduces a few new files and directories for this purpose, as well as a sample view that will only be seen in dev mode, and never in production. --- cms/djangoapps/contentstore/views/__init__.py | 4 ++++ cms/djangoapps/contentstore/views/dev.py | 5 +++++ cms/templates/dev/dev_mode.html | 4 ++++ cms/urls.py | 11 ++++++++--- cms/urls_dev.py | 5 +++++ 5 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 cms/djangoapps/contentstore/views/dev.py create mode 100644 cms/templates/dev/dev_mode.html create mode 100644 cms/urls_dev.py diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index f3e98ec216..197c54ff36 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -15,3 +15,7 @@ from .public import * from .user import * from .tabs import * from .requests import * +try: + from .dev import * +except ImportError: + pass diff --git a/cms/djangoapps/contentstore/views/dev.py b/cms/djangoapps/contentstore/views/dev.py new file mode 100644 index 0000000000..1c4f0d643e --- /dev/null +++ b/cms/djangoapps/contentstore/views/dev.py @@ -0,0 +1,5 @@ +from mitxmako.shortcuts import render_to_response + + +def dev_mode(request): + return render_to_response("dev/dev_mode.html") diff --git a/cms/templates/dev/dev_mode.html b/cms/templates/dev/dev_mode.html new file mode 100644 index 0000000000..9ee409d5de --- /dev/null +++ b/cms/templates/dev/dev_mode.html @@ -0,0 +1,4 @@ +<%inherit file="../base.html" /> +<%block name="content"> +You're in dev mode! +</%block> diff --git a/cms/urls.py b/cms/urls.py index def1ad1bf8..5945394f55 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -137,9 +137,7 @@ urlpatterns += ( if settings.ENABLE_JASMINE: - # # Jasmine - urlpatterns = urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),) - + urlpatterns += (url(r'^_jasmine/', include('django_jasmine.urls')),) if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): urlpatterns += ( @@ -154,6 +152,13 @@ if settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING'): url(r'^auto_auth$', 'student.views.auto_auth'), ) +if settings.DEBUG: + try: + from .urls_dev import urlpatterns as dev_urlpatterns + urlpatterns += dev_urlpatterns + except ImportError: + pass + urlpatterns = patterns(*urlpatterns) # Custom error pages diff --git a/cms/urls_dev.py b/cms/urls_dev.py new file mode 100644 index 0000000000..ce2cecc8fe --- /dev/null +++ b/cms/urls_dev.py @@ -0,0 +1,5 @@ +from django.conf.urls import url + +urlpatterns = ( + url(r'^dev_mode$', 'contentstore.views.dev.dev_mode', name='dev_mode'), +) From 0d3c44996a55b77aa02a6460b4e34dd3bcaa5526 Mon Sep 17 00:00:00 2001 From: Calen Pennington <cale@edx.org> Date: Thu, 18 Jul 2013 09:43:22 -0400 Subject: [PATCH 287/556] LMS i18n from Tsinghua --- conf/locale/babel.cfg | 2 + conf/locale/config | 2 +- lms/djangoapps/course_wiki/tests/tests.py | 4 +- lms/djangoapps/courseware/tests/tests.py | 5 +- lms/envs/common.py | 7 +- lms/templates/admin_dashboard.html | 3 +- lms/templates/annotatable.html | 10 +- .../combined_open_ended.html | 5 +- .../combined_open_ended_legend.html | 3 +- .../combined_open_ended_status.html | 3 +- .../open_ended_result_table.html | 23 +- .../openended/open_ended.html | 11 +- .../openended/open_ended_error.html | 5 +- .../openended/open_ended_evaluation.html | 21 +- .../openended/open_ended_rubric.html | 5 +- .../selfassessment/self_assessment_hint.html | 3 +- .../self_assessment_prompt.html | 5 +- lms/templates/contact.html | 44 +- lms/templates/course.html | 7 +- lms/templates/course_filter.html | 18 +- .../course_groups/cohort_management.html | 13 +- lms/templates/course_groups/debug.html | 2 + lms/templates/courseware/accordion.html | 17 +- lms/templates/courseware/course_about.html | 54 +- .../courseware/course_navigation.html | 3 +- lms/templates/courseware/courses.html | 7 +- .../courseware/courseware-error.html | 8 +- lms/templates/courseware/courseware.html | 21 +- lms/templates/courseware/grade_summary.html | 5 +- lms/templates/courseware/gradebook.html | 3 +- lms/templates/courseware/info.html | 13 +- .../courseware/instructor_dashboard.html | 256 +++--- .../courseware/mktg_coming_soon.html | 5 +- .../courseware/mktg_course_about.html | 17 +- lms/templates/courseware/news.html | 5 +- lms/templates/courseware/notifications.html | 36 +- lms/templates/courseware/progress.html | 7 +- lms/templates/courseware/syllabus.html | 7 +- lms/templates/courseware/welcome-back.html | 8 +- lms/templates/dashboard.html | 121 +-- lms/templates/debug/run_python_form.html | 3 +- lms/templates/discussion/_blank_slate.html | 5 +- .../_discussion_course_navigation.html | 5 +- .../discussion/_discussion_module.html | 5 +- .../discussion/_filter_dropdown.html | 7 +- .../discussion/_inline_new_post.html | 17 +- lms/templates/discussion/_new_post.html | 19 +- .../discussion/_recent_active_posts.html | 3 +- lms/templates/discussion/_search_bar.html | 3 +- lms/templates/discussion/_similar_posts.html | 3 +- lms/templates/discussion/_single_thread.html | 3 +- .../discussion/_thread_list_template.html | 23 +- .../discussion/_underscore_templates.html | 41 +- lms/templates/discussion/_user_profile.html | 5 +- lms/templates/discussion/index.html | 3 +- lms/templates/discussion/maintenance.html | 5 +- lms/templates/discussion/single_thread.html | 3 +- lms/templates/discussion/user_profile.html | 7 +- lms/templates/email_change_failed.html | 8 +- lms/templates/email_change_successful.html | 9 +- lms/templates/email_exists.html | 8 +- lms/templates/emails_change_successful.html | 9 +- lms/templates/enroll_students.html | 22 +- lms/templates/extauth_failure.html | 6 +- lms/templates/folditbasic.html | 16 +- lms/templates/folditchallenge.html | 8 +- lms/templates/footer.html | 19 +- lms/templates/forgot_password_modal.html | 22 +- lms/templates/help_modal.html | 95 ++- lms/templates/index.html | 33 +- lms/templates/instructor/staff_grading.html | 29 +- lms/templates/invalid_email_key.html | 14 +- lms/templates/licenses/serial_numbers.html | 3 +- lms/templates/login.html | 46 +- lms/templates/login_modal.html | 18 +- lms/templates/main.html | 9 +- lms/templates/main_django.html | 19 +- lms/templates/module-error.html | 12 +- lms/templates/name_changes.html | 16 +- lms/templates/navigation.html | 33 +- lms/templates/notes.html | 17 +- .../combined_notifications.html | 11 +- .../open_ended_flagged_problems.html | 19 +- .../open_ended_problems.html | 25 +- lms/templates/peer_grading/peer_grading.html | 23 +- .../peer_grading/peer_grading_closed.html | 9 +- .../peer_grading/peer_grading_problem.html | 51 +- lms/templates/problem.html | 10 +- lms/templates/provider_login.html | 16 +- lms/templates/register.html | 110 +-- .../registration/activate_account_notice.html | 6 +- .../registration/activation_complete.html | 17 +- .../registration/activation_invalid.html | 13 +- lms/templates/registration/login.html | 16 +- lms/templates/registration/logout.html | 9 +- .../registration/password_reset_complete.html | 7 +- .../registration/password_reset_confirm.html | 43 +- .../registration/password_reset_done.html | 5 +- .../registration/registration_complete.html | 2 +- .../registration/registration_form.html | 35 +- lms/templates/seq_module.html | 14 +- lms/templates/signup_modal.html | 76 +- lms/templates/staff_problem_info.html | 34 +- lms/templates/static_htmlbook.html | 24 +- lms/templates/static_pdfbook.html | 213 ++--- lms/templates/static_templates/404.html | 6 +- lms/templates/static_templates/about.html | 31 +- lms/templates/static_templates/contact.html | 48 +- lms/templates/static_templates/copyright.html | 19 +- lms/templates/static_templates/faq.html | 91 +-- lms/templates/static_templates/help.html | 231 +++--- lms/templates/static_templates/honor.html | 31 +- lms/templates/static_templates/jobs.html | 752 +++++++++--------- lms/templates/static_templates/media-kit.html | 45 +- lms/templates/static_templates/press.html | 13 +- lms/templates/static_templates/privacy.html | 95 +-- .../static_templates/server-down.html | 5 +- .../static_templates/server-error.html | 5 +- .../static_templates/server-overloaded.html | 5 +- lms/templates/static_templates/tos.html | 127 +-- lms/templates/staticbook.html | 14 +- lms/templates/stripped-main.html | 3 + lms/templates/test_center_register.html | 192 ++--- lms/templates/tracking_log.html | 6 +- lms/templates/university_profile/anux.html | 7 +- .../university_profile/berkeleyx.html | 7 +- lms/templates/university_profile/delftx.html | 7 +- lms/templates/university_profile/edge.html | 23 +- lms/templates/university_profile/epflx.html | 13 +- .../university_profile/georgetownx.html | 7 +- .../university_profile/harvardx.html | 9 +- lms/templates/university_profile/mcgillx.html | 7 +- lms/templates/university_profile/mitx.html | 11 +- lms/templates/university_profile/ricex.html | 7 +- .../university_profile/template.html | 7 +- .../university_profile/torontox.html | 7 +- .../university_profile/utaustinx.html | 7 +- lms/templates/university_profile/utx.html | 9 +- .../university_profile/wellesleyx.html | 7 +- lms/templates/using.html | 10 +- lms/templates/video.html | 8 +- lms/templates/videoalpha.html | 6 +- lms/templates/wiki/article.html | 2 +- lms/templates/wiki/includes/article_menu.html | 7 +- lms/templates/wiki/includes/cheatsheet.html | 47 +- .../wiki/includes/editor_widget.html | 3 +- .../wiki/plugins/attachments/index.html | 7 +- lms/templates/wiki/preview_inline.html | 5 +- lms/templates/word_cloud.html | 4 +- lms/urls.py | 12 + 150 files changed, 2230 insertions(+), 1948 deletions(-) diff --git a/conf/locale/babel.cfg b/conf/locale/babel.cfg index 5b8333cf1e..6631586ad4 100644 --- a/conf/locale/babel.cfg +++ b/conf/locale/babel.cfg @@ -17,3 +17,5 @@ input_encoding = utf-8 input_encoding = utf-8 [mako: common/templates/**.html] input_encoding = utf-8 +[mako: cms/templates/emails/**.txt] +input_encoding = utf-8 diff --git a/conf/locale/config b/conf/locale/config index 58f8da0513..3a0b04adbb 100644 --- a/conf/locale/config +++ b/conf/locale/config @@ -1,4 +1,4 @@ { - "locales" : ["en", "es"], + "locales" : ["en", "zh_CN"], "dummy-locale" : "fr" } diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py index 663d6b53b2..6bbd8011d6 100644 --- a/lms/djangoapps/course_wiki/tests/tests.py +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -90,8 +90,8 @@ class WikiRedirectTestCase(LoginEnrollmentTestCase): """ Ensure that the response has the course navigator. """ - self.assertTrue("course info" in resp.content.lower()) - self.assertTrue("courseware" in resp.content.lower()) + self.assertContains(resp, "Course Info") + self.assertContains(resp, "courseware") def test_course_navigator(self): """" diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index fbe2c05ada..cd245d2610 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -120,9 +120,8 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): self.assertEqual(response.redirect_chain[0][1], 302) if check_content: - unavailable_msg = "this module is temporarily unavailable" - self.assertEqual(response.content.find(unavailable_msg), -1) - self.assertFalse(isinstance(descriptor, ErrorDescriptor)) + self.assertNotContains(response, "this module is temporarily unavailable") + self.assertNotIsInstance(descriptor, ErrorDescriptor) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) diff --git a/lms/envs/common.py b/lms/envs/common.py index 95b2af422e..29e0de7d91 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -166,7 +166,7 @@ XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds ############################# SET PATH INFORMATION ############################# -PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/lms +PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /edx-platform/lms REPO_ROOT = PROJECT_ROOT.dirname() COMMON_ROOT = REPO_ROOT / "common" ENV_ROOT = REPO_ROOT.dirname() # virtualenv dir /mitx is in @@ -381,6 +381,8 @@ LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html USE_I18N = True USE_L10N = True +# Localization strings (e.g. django.po) are under this directory +LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # edx-platform/conf/locale/ # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -486,6 +488,9 @@ MIDDLEWARE_CLASSES = ( 'course_wiki.course_nav.Middleware', + # Detects user-requested locale from 'accept-language' header in http request + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.transaction.TransactionMiddleware', # 'debug_toolbar.middleware.DebugToolbarMiddleware', diff --git a/lms/templates/admin_dashboard.html b/lms/templates/admin_dashboard.html index 6a903a3f94..b9676faa16 100644 --- a/lms/templates/admin_dashboard.html +++ b/lms/templates/admin_dashboard.html @@ -1,4 +1,5 @@ <%namespace name='static' file='static_content.html'/> +<%! from django.utils.translation import ugettext as _ %> <%inherit file="main.html" /> @@ -7,7 +8,7 @@ <section class="basic_stats"> <div class="edx_summary"> - <h2>edX-wide Summary</h2> + <h2>${_("edX-wide Summary")}</h2> <table style="margin-left:auto;margin-right:auto;width:50%"> % for key in results["scalars"]: <tr> diff --git a/lms/templates/annotatable.html b/lms/templates/annotatable.html index f010305744..20a85d0ca2 100644 --- a/lms/templates/annotatable.html +++ b/lms/templates/annotatable.html @@ -1,3 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + <div class="annotatable-wrapper"> <div class="annotatable-header"> % if display_name is not UNDEFINED and display_name is not None: @@ -8,8 +10,8 @@ % if instructions_html is not UNDEFINED and instructions_html is not None: <div class="annotatable-section shaded"> <div class="annotatable-section-title"> - Instructions - <a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a> + ${_("Instructions")} + <a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">${_("Collapse Instructions")}</a> </div> <div class="annotatable-section-body annotatable-instructions"> ${instructions_html} @@ -19,8 +21,8 @@ <div class="annotatable-section"> <div class="annotatable-section-title"> - Guided Discussion - <a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a> + ${_("Guided Discussion")} + <a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">${_("Hide Annotations")}</a> </div> <div class="annotatable-section-body annotatable-content"> ${content_html} diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 5d8ef859aa..50f962d691 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> <section id="combined-open-ended" class="combined-open-ended" data-location="${location}" data-ajax-url="${ajax_url}" data-allow_reset="${allow_reset}" data-state="${state}" data-task-count="${task_count}" data-task-number="${task_number}" data-accept-file-upload = "${accept_file_upload}"> <div class="status-container"> ${status|n} @@ -12,8 +13,8 @@ % endfor </div> - <input type="button" value="Reset" class="reset-button" name="reset"/> - <input type="button" value="Next Step" class="next-step-button" name="reset"/> + <input type="button" value="${_("Reset")}" class="reset-button" name="reset"/> + <input type="button" value="${_("Next Step")}" class="next-step-button" name="reset"/> </div> <section class="legend-container"> diff --git a/lms/templates/combinedopenended/combined_open_ended_legend.html b/lms/templates/combinedopenended/combined_open_ended_legend.html index e3e2494670..d5d482e190 100644 --- a/lms/templates/combinedopenended/combined_open_ended_legend.html +++ b/lms/templates/combinedopenended/combined_open_ended_legend.html @@ -1,6 +1,7 @@ +<%! from django.utils.translation import ugettext as _ %> <section class="legend-container"> <div class="legenditem"> - Legend + ${_("Legend")} </div> % for i in xrange(0,len(legend_list)): <%legend_title=legend_list[i]['name'] %> diff --git a/lms/templates/combinedopenended/combined_open_ended_status.html b/lms/templates/combinedopenended/combined_open_ended_status.html index d13077737f..0369d6d9ff 100644 --- a/lms/templates/combinedopenended/combined_open_ended_status.html +++ b/lms/templates/combinedopenended/combined_open_ended_status.html @@ -1,7 +1,8 @@ +<%! from django.utils.translation import ugettext as _ %> <div class="status-elements"> <section id="combined-open-ended-status" class="combined-open-ended-status"> <div class="statusitem"> - Status + ${_("Status")} </div> %for i in xrange(0,len(status_list)): <%status=status_list[i]%> diff --git a/lms/templates/combinedopenended/open_ended_result_table.html b/lms/templates/combinedopenended/open_ended_result_table.html index 24bf7a76fe..bac684b91c 100644 --- a/lms/templates/combinedopenended/open_ended_result_table.html +++ b/lms/templates/combinedopenended/open_ended_result_table.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> % for co in context_list: % if co['grader_type'] in grader_type_image_dict: <%grader_type=co['grader_type']%> @@ -18,7 +19,7 @@ %if len(co['feedback'])>2: <div class="collapsible evaluation-response"> <header> - <a href="#">See full feedback</a> + <a href="#">${_("See full feedback")}</a> </header> <section class="feedback-full"> ${co['feedback']} @@ -32,22 +33,22 @@ <input type="hidden" value="${co['submission_id']}" class="submission_id" /> <div class="collapsible evaluation-response"> <header> - <a href="#">Respond to Feedback</a> + <a href="#">${_("Respond to Feedback")}</a> </header> <section id="evaluation" class="evaluation"> - <p>How accurate do you find this feedback?</p> + <p>${_("How accurate do you find this feedback?")}</p> <div class="evaluation-scoring"> <ul class="scoring-list"> - <li><input type="radio" name="evaluation-score" id="evaluation-score-5" value="5" /> <label for="evaluation-score-5"> Correct</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-4" value="4" /> <label for="evaluation-score-4"> Partially Correct</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-3" value="3" /> <label for="evaluation-score-3"> No Opinion</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-2" value="2" /> <label for="evaluation-score-2"> Partially Incorrect</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-1" value="1" /> <label for="evaluation-score-1"> Incorrect</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-5" value="5" /> <label for="evaluation-score-5"> ${_("Correct")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-4" value="4" /> <label for="evaluation-score-4"> ${_("Partially Correct")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-3" value="3" /> <label for="evaluation-score-3"> ${_("No Opinion")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-2" value="2" /> <label for="evaluation-score-2"> ${_("Partially Incorrect")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-1" value="1" /> <label for="evaluation-score-1"> ${_("Incorrect")}</label></li> </ul> </div> - <p>Additional comments:</p> + <p>${_("Additional comments:")}</p> <textarea rows="${rows}" cols="${cols}" name="feedback" class="feedback-on-feedback" id="feedback"></textarea> - <input type="button" value="Submit Feedback" class="submit-evaluation-button" name="reset"/> + <input type="button" value="${_("Submit Feedback")}" class="submit-evaluation-button" name="reset"/> </section> </div> </div> @@ -55,4 +56,4 @@ </section> <br/> %endif -%endfor \ No newline at end of file +%endfor diff --git a/lms/templates/combinedopenended/openended/open_ended.html b/lms/templates/combinedopenended/openended/open_ended.html index 909ef15838..d4e622d4bd 100644 --- a/lms/templates/combinedopenended/openended/open_ended.html +++ b/lms/templates/combinedopenended/openended/open_ended.html @@ -1,17 +1,18 @@ +<%! from django.utils.translation import ugettext as _ %> <section id="openended_${id}" class="open-ended-child" data-state="${state}" data-child-type="${child_type}"> <div class="error"></div> <div class="prompt"> ${prompt|n} </div> - <h4>Response</h4> + <h4>${_("Response")}</h4> <textarea rows="${rows}" cols="${cols}" name="answer" class="answer short-form-response" id="input_${id}">${previous_answer|h}</textarea> <div class="message-wrapper"></div> <div class="grader-status"> % if state == 'initial': - <span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span> + <span class="unanswered" style="display:inline-block;" id="status_${id}">${_(Unanswered)}</span> % elif state == 'assessing': - <span class="grading" id="status_${id}">Submitted for grading. + <span class="grading" id="status_${id}">${_("Submitted for grading.")} % if eta_message is not None: ${eta_message} % endif @@ -26,8 +27,8 @@ <div class="file-upload"></div> - <input type="button" value="Submit" class="submit-button" name="show"/> - <input name="skip" class="skip-button" type="button" value="Skip Post-Assessment"/> + <input type="button" value="${_("Submit")}" class="submit-button" name="show"/> + <input name="skip" class="skip-button" type="button" value="${_("Skip Post-Assessment")}"/> <div class="open-ended-action"></div> diff --git a/lms/templates/combinedopenended/openended/open_ended_error.html b/lms/templates/combinedopenended/openended/open_ended_error.html index 58a90f86ef..65b7381d60 100644 --- a/lms/templates/combinedopenended/openended/open_ended_error.html +++ b/lms/templates/combinedopenended/openended/open_ended_error.html @@ -1,7 +1,8 @@ +<%! from django.utils.translation import ugettext as _ %> <section> <div class="shortform"> <div class="result-errors"> - There was an error with your submission. Please contact course staff. + ${_("There was an error with your submission. Please contact course staff.")} </div> </div> <div class="longform"> @@ -9,4 +10,4 @@ ${errors} </div> </div> -</section> \ No newline at end of file +</section> diff --git a/lms/templates/combinedopenended/openended/open_ended_evaluation.html b/lms/templates/combinedopenended/openended/open_ended_evaluation.html index da3f38b6a9..ee55120d51 100644 --- a/lms/templates/combinedopenended/openended/open_ended_evaluation.html +++ b/lms/templates/combinedopenended/openended/open_ended_evaluation.html @@ -1,23 +1,24 @@ +<%! from django.utils.translation import ugettext as _ %> <div class="external-grader-message"> ${msg|n} <div class="collapsible evaluation-response"> <header> - <a href="#">Respond to Feedback</a> + <a href="#">${_("Respond to Feedback")}</a> </header> <section id="evaluation_${id}" class="evaluation"> - <p>How accurate do you find this feedback?</p> + <p>${_("How accurate do you find this feedback?")}</p> <div class="evaluation-scoring"> <ul class="scoring-list"> - <li><input type="radio" name="evaluation-score" id="evaluation-score-5" value="5" /> <label for="evaluation-score-5"> Correct</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-4" value="4" /> <label for="evaluation-score-4"> Partially Correct</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-3" value="3" /> <label for="evaluation-score-3"> No Opinion</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-2" value="2" /> <label for="evaluation-score-2"> Partially Incorrect</label></li> - <li><input type="radio" name="evaluation-score" id="evaluation-score-1" value="1" /> <label for="evaluation-score-1"> Incorrect</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-5" value="5" /> <label for="evaluation-score-5"> ${_("Correct")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-4" value="4" /> <label for="evaluation-score-4"> ${_("Partially Correct")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-3" value="3" /> <label for="evaluation-score-3"> ${_("No Opinion")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-2" value="2" /> <label for="evaluation-score-2"> ${_("Partially Incorrect")}</label></li> + <li><input type="radio" name="evaluation-score" id="evaluation-score-1" value="1" /> <label for="evaluation-score-1"> ${_("Incorrect")}</label></li> </ul> </div> - <p>Additional comments:</p> + <p>${_("Additional comments:")}</p> <textarea rows="${rows}" cols="${cols}" name="feedback_${id}" class="feedback-on-feedback" id="feedback_${id}"></textarea> - <input type="button" value="Submit Feedback" class="submit-evaluation-button" name="reset"/> + <input type="button" value="${_("Submit Feedback")}" class="submit-evaluation-button" name="reset"/> </section> </div> -</div> \ No newline at end of file +</div> diff --git a/lms/templates/combinedopenended/openended/open_ended_rubric.html b/lms/templates/combinedopenended/openended/open_ended_rubric.html index 144cd829d9..f1d6abb8fa 100644 --- a/lms/templates/combinedopenended/openended/open_ended_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_rubric.html @@ -1,6 +1,7 @@ +<%! from django.utils.translation import ugettext as _ %> <form class="rubric-template" id="inputtype_${id}" xmlns="http://www.w3.org/1999/html"> - <h3>Rubric</h3> - <p>Select the criteria you feel best represents this submission in each category.</p> + <h3>${_("Rubric")}</h3> + <p>${_("Select the criteria you feel best represents this submission in each category.")}</p> <div class="rubric"> % for i in range(len(categories)): <% category = categories[i] %> diff --git a/lms/templates/combinedopenended/selfassessment/self_assessment_hint.html b/lms/templates/combinedopenended/selfassessment/self_assessment_hint.html index 8c6eacba11..abdc25b77b 100644 --- a/lms/templates/combinedopenended/selfassessment/self_assessment_hint.html +++ b/lms/templates/combinedopenended/selfassessment/self_assessment_hint.html @@ -1,6 +1,7 @@ +<%! from django.utils.translation import ugettext as _ %> <div class="hint"> <div class="hint-prompt"> - Please enter a hint below: + ${_("Please enter a hint below:")} </div> <textarea name="post_assessment" class="post_assessment" cols="70" rows="5" ${'readonly="true"' if read_only else ''}>${hint}</textarea> diff --git a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html index 5347e23844..3cc73fc657 100644 --- a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html +++ b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> <section id="self_assessment_${id}" class="open-ended-child" data-ajax-url="${ajax_url}" data-id="${id}" data-state="${state}" data-allow_reset="${allow_reset}" data-child-type="${child_type}"> <div class="error"></div> @@ -5,7 +6,7 @@ ${prompt} </div> - <h4>Response</h4> + <h4>${_("Response")}</h4> <div> <textarea name="answer" class="answer short-form-response" cols="70" rows="20">${previous_answer|n}</textarea> </div> @@ -19,5 +20,5 @@ <div class="message-wrapper"></div> <div class="file-upload"></div> - <input type="button" value="Submit" class="submit-button" name="show"/> + <input type="button" value="${_("Submit")}" class="submit-button" name="show"/> </section> diff --git a/lms/templates/contact.html b/lms/templates/contact.html index a8f5e6b732..ba6f8cc720 100644 --- a/lms/templates/contact.html +++ b/lms/templates/contact.html @@ -1,13 +1,15 @@ +<%! from django.utils.translation import ugettext as _ %> + <%namespace name='static' file='static_content.html'/> <%inherit file="main.html" /> <section class="container about"> <nav> - <a href="/t/about.html">Vision</a> - <a href="/t/faq.html">Faq</a> - <a href="/t/press.html">Press</a> - <a href="/t/contact.html" class="active">Contact</a> + <a href="/t/about.html">${_("Vision")}</a> + <a href="/t/faq.html">${_("Faq")}</a> + <a href="/t/press.html">${_("Press")}</a> + <a href="/t/contact.html" class="active">${_("Contact")}</a> </nav> <section class="contact"> @@ -15,21 +17,33 @@ <img src="${static.url('images/contact-page.jpg')}"> </div> <div class="contacts"> - <h2>Class Feedback</h2> - <p>We are always seeking feedback to improve our courses. If you are an enrolled student and have any questions, feedback, suggestions, or any other issues specific to a particular class, please post on the discussion forums of that class.</p> + <h2>${_("Class Feedback")}</h2> + <p>${_("We are always seeking feedback to improve our courses. If you are an enrolled student and have any questions, feedback, suggestions, or any other issues specific to a particular class, please post on the discussion forums of that class.")}</p> - <h2>General Inquiries and Feedback</h2> - <p>"If you have a general question about edX please email <a href="mailto:info@edx.org">info@edx.org</a>. To see if your question has already been answered, visit our <a href="${reverse('faq_edx')}">FAQ page</a>. You can also join the discussion on our <a href="http://www.facebook.com/EdxOnline">facebook page</a>. Though we may not have a chance to respond to every email, we take all feedback into consideration.</p> + <h2>${_("General Inquiries and Feedback")}</h2> + <p>${_('If you have a general question about edX please email {email}. To see if your question has already been answered, visit our {faq_link_start}FAQ page{faq_link_end}. You can also join the discussion on our {fb_link_start}facebook page{fb_link_end}. Though we may not have a chance to respond to every email, we take all feedback into consideration.').format( + email='<a href="mailto:info@edx.org">info@edx.org</a>', + faq_link_start='<a href="{url}">'.format(url=reverse('faq_edx')), + faq_link_end='</a>', + fb_link_start='<a href="http://www.facebook.com/EdxOnline">'. + fb_link_end='</a>' + )}</p> - <h2>Technical Inquiries and Feedback</h2> - <p>If you have suggestions/feedback about the overall edX platform, or are facing general technical issues with the platform (e.g., issues with email addresses and passwords), you can reach us at <a href="mailto:technical@edx.org">technical@edx.org</a>. For technical questions, please make sure you are using a current version of Firefox or Chrome, and include browser and version in your e-mail, as well as screenshots or other pertinent details. If you find a bug or other issues, you can reach us at the following: <a href="mailto:bugs@edx.org">bugs@edx.org</a>.</p> + <h2>${_("Technical Inquiries and Feedback")}</h2> + <p>${_('If you have suggestions/feedback about the overall edX platform, or are facing general technical issues with the platform (e.g., issues with email addresses and passwords), you can reach us at {tech_email}. For technical questions, please make sure you are using a current version of Firefox or Chrome, and include browser and version in your e-mail, as well as screenshots or other pertinent details. If you find a bug or other issues, you can reach us at the following: {bug_email}.').format( + tech_email='<a href="mailto:technical@edx.org">technical@edx.org</a>', + bug_email='<a href="mailto:bugs@edx.org">bugs@edx.org</a>' + )}</p> - <h2>Media</h2> - <p>Please visit our <a href="${reverse('faq_edx')}">media/press page</a> for more information. 
For any media or press inquiries, please email <a href="mailto:press@edx.org">press@edx.org</a>.</p> - - <h2>Universities</h2> - <p>If you are a university wishing to Collaborate or with questions about edX, please email <a href="mailto:university@edx.org">university@edx.org</a>.</p> + <h2>${_("Media")}</h2> + <p>${_('Please visit our {link_start}media/press page{link_end} for more information. For any media or press inquiries, please email {email}.').format( + link_start='<a href="{url}">'.format(url=reverse('faq_edx')), + link_end='</a>', + email='<a href="mailto:press@edx.org">press@edx.org</a>', + )}</p> + <h2>${_("Universities")}</h2> + <p>${_('If you are a university wishing to Collaborate or with questions about edX, please email {email}.'.format(email='<a href="mailto:university@edx.org">university@edx.org</a>')}</p> </div> </section> </section> diff --git a/lms/templates/course.html b/lms/templates/course.html index e3dd9baf43..4b2133e1af 100644 --- a/lms/templates/course.html +++ b/lms/templates/course.html @@ -1,13 +1,14 @@ <%namespace name='static' file='static_content.html'/> <%namespace file='main.html' import="stanford_theme_enabled"/> <%! - from django.core.urlresolvers import reverse - from courseware.courses import course_image_url, get_course_about_section +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +from courseware.courses import course_image_url, get_course_about_section %> <%page args="course" /> <article id="${course.id}" class="course"> %if course.is_newish: - <span class="status">New</span> + <span class="status">${_("New")}</span> %endif <a href="${reverse('about_course', args=[course.id])}"> <div class="inner-wrapper"> diff --git a/lms/templates/course_filter.html b/lms/templates/course_filter.html index 9e7c0a16f4..8e2268cad1 100644 --- a/lms/templates/course_filter.html +++ b/lms/templates/course_filter.html @@ -1,8 +1,10 @@ +<%! from django.utils.translation import ugettext as _ %> + <section class="filter"> <nav> <div class="dropdown university"> <div class="filter-heading"> - All Universities + ${_("All Universities")} </div> <ul> <li> @@ -16,34 +18,34 @@ <div class="dropdown subject"> <div class="filter-heading"> - All Subjects + ${_("All Subjects")} </div> <ul> <li> - <a href="#">Computer Science</a> + <a href="#">${_("Computer Science")}</a> </li> <li> - <a href="#">History</a> + <a href="#">${_("History")}</a> </li> </ul> </div> <div class="dropdown featured"> <div class="filter-heading"> - Newest + ${_("Newest")} </div> <ul> <li> - <a href="#">Top Rated</a> + <a href="#">${_("Top Rated")}</a> </li> <li> - <a href="#">Starting soonest</a> + <a href="#">${_("Starting soonest")}</a> </li> </ul> </div> <form class="search"> - <input type="text" placeholder="Search for courses"> + <input type="text" placeholder="${_('Search for courses')}"> <input type="submit"> </form> </nav> diff --git a/lms/templates/course_groups/cohort_management.html b/lms/templates/course_groups/cohort_management.html index 239863beeb..4c9ca9e5a2 100644 --- a/lms/templates/course_groups/cohort_management.html +++ b/lms/templates/course_groups/cohort_management.html @@ -1,21 +1,22 @@ +<%! from django.utils.translation import ugettext as _ %> <section class="cohort_manager" data-ajax_url="${cohorts_ajax_url}"> -<h3>Cohort groups</h3> +<h3>${_("Cohort groups")}</h3> <div class="controls" style="padding-top:15px"> - <a href="#" class="button show_cohorts">Show cohorts</a> + <a href="#" class="button show_cohorts">${_("Show cohorts")}</a> </div> <ul class="errors"> </ul> <div class="summary" style="display:none"> - <h3>Cohorts in the course</h3> + <h3>${_("Cohorts in the course")}</h3> <ul class="cohorts"> </ul> <p> <input class="cohort_name"/> - <a href="#" class="button add_cohort">Add cohort</a> + <a href="#" class="button add_cohort">${_("Add cohort")}</a> </p> </div> @@ -27,10 +28,10 @@ <span class="page_num"></span> <p> - Add users by username or email. One per line or comma-separated. + ${_("Add users by username or email. One per line or comma-separated.")} </p> <textarea cols="50" row="30" class="users_area" style="height: 200px"></textarea> - <a href="#" class="button add_members">Add cohort members</a> + <a href="#" class="button add_members">${_("Add cohort members")}</a> <ul class="op_results"> </ul> diff --git a/lms/templates/course_groups/debug.html b/lms/templates/course_groups/debug.html index d8bbc324de..7554557f81 100644 --- a/lms/templates/course_groups/debug.html +++ b/lms/templates/course_groups/debug.html @@ -1,6 +1,8 @@ <!DOCTYPE html> +<%! from django.utils.translation import ugettext as _ %> <html> <head> + ## "edX" should not be translated <%block name="title"><title>edX diff --git a/lms/templates/courseware/accordion.html b/lms/templates/courseware/accordion.html index 5b9c6f7450..4761408232 100644 --- a/lms/templates/courseware/accordion.html +++ b/lms/templates/courseware/accordion.html @@ -1,9 +1,20 @@ -<%! from django.core.urlresolvers import reverse %> -<%! from xmodule.util.date_utils import get_default_time_display %> +<%! + from django.core.urlresolvers import reverse + from xmodule.util.date_utils import get_default_time_display + from django.utils.translation import ugettext as _ +%> <%def name="make_chapter(chapter)">
    -

    + <% + if chapter.get('active'): + aria_label = _('{chapter}, current chapter').format(chapter=chapter['display_name']) + active_class = ' class="active"' + else: + aria_label = chapter['display_name'] + active_class = '' + %> +

    ${chapter['display_name']} diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 15317de207..0f17af5fee 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse from courseware.courses import course_image_url, get_course_about_section @@ -41,9 +42,9 @@ ).css("display", "block"); } }); - + %else: - + $('#class_enroll_form').on('ajax:complete', function(event, xhr) { if(xhr.status == 200) { location.href = "${reverse('dashboard')}"; @@ -51,21 +52,21 @@ location.href = "${reverse('register_user')}?course_id=${course.id}&enrollment_action=enroll"; } else { $('#register_error').html( - (xhr.responseText ? xhr.responseText : 'An error occurred. Please try again later.') + (xhr.responseText ? xhr.responseText : ${_('An error occurred. Please try again later.')}) ).css("display", "block"); } }); %endif - - + + })(this) -<%block name="title">About ${course.number} +<%block name="title">${_("About {course_number}").format(course_number=course.number)}
    @@ -86,13 +87,13 @@ %if show_courseware_link: %endif - You are registered for this course (${course.number}) + ${_("You are registered for this course {course_number}").format(course_number=course.number)} %if show_courseware_link: - View Courseware + ${_("View Courseware")} %endif %else: - Register for ${course.number} + ${_("Register for {course_number}").format(course_number=course.number)}
    %endif

    @@ -115,16 +116,16 @@
    - +
    @@ -136,7 +137,7 @@
      -
    1. Course Number

      ${course.number}
    2. -
    3. Classes Start

      ${course.start_date_text}
    4. +
    5. ${_("Course Number")}

      ${course.number}
    6. +
    7. ${_("Classes Start")}

      ${course.start_date_text}
    8. ## We plan to ditch end_date (which is not stored in course metadata), ## but for backwards compatibility, show about/end_date blob if it exists. % if get_course_about_section(course, "end_date") or course.end:
    9. -

      Classes End

      +

      ${_("Classes End")}

      % if get_course_about_section(course, "end_date"): ${get_course_about_section(course, "end_date")} % else: @@ -180,13 +181,13 @@ % endif % if get_course_about_section(course, "effort"): -
    10. Estimated Effort

      ${get_course_about_section(course, "effort")}
    11. +
    12. ${_("Estimated Effort")}

      ${get_course_about_section(course, "effort")}
    13. % endif - ##
    14. Course Length

      15 weeks
    15. + ##
    16. ${_('Course Length')}

      ${_('{number} weeks').format(number=15)}
    17. % if get_course_about_section(course, "prerequisites"): -
    18. Prerequisites

      ${get_course_about_section(course, "prerequisites")}
    19. +
    20. ${_("Prerequisites")}

      ${get_course_about_section(course, "prerequisites")}
    21. % endif
    @@ -196,10 +197,11 @@ % if get_course_about_section(course, "ocw_links"):
    -

    Additional Resources

    +

    ${_("Additional Resources")}

    + ## "MITOpenCourseware" should *not* be translated

    MITOpenCourseware

    ${get_course_about_section(course, "ocw_links")}
    @@ -215,10 +217,10 @@
    - +
    - +
    diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html index 98329b9836..799b10b36b 100644 --- a/lms/templates/courseware/course_navigation.html +++ b/lms/templates/courseware/course_navigation.html @@ -12,6 +12,7 @@ def url_class(is_active): return "" %> <%! from courseware.tabs import get_course_tabs %> +<%! from django.utils.translation import ugettext as _ %>
    diff --git a/lms/templates/courseware/courseware-error.html b/lms/templates/courseware/courseware-error.html index e289e1c99d..3c61afd5d9 100644 --- a/lms/templates/courseware/courseware-error.html +++ b/lms/templates/courseware/courseware-error.html @@ -1,7 +1,9 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="/main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">courseware -<%block name="title">Courseware – edX +## "edX" should *not* be translated +<%block name="title">${_("Courseware")} - edX <%block name="headextra"> <%static:css group='course'/> @@ -11,7 +13,7 @@
    -

    There has been an error on the edX servers

    -

    We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at technical@edx.org to report any problems or downtime.

    +

    ${_('There has been an error on the {span_start}edX{span_end} servers').format(span_start='', span_end='')}

    +

    ${_("We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at {email} to report any problems or downtime.").format(email='technical@edx.org')}

    diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index e009e535e3..8d033434f0 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -1,7 +1,8 @@ +<%! from django.utils.translation import ugettext as _ %> <%inherit file="/main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="bodyclass">courseware ${course.css_class} -<%block name="title">${course.number} Courseware +<%block name="title">${_("{course_number} Courseware").format(course_number=course.number)} <%block name="headextra"> <%static:css group='course'/> @@ -155,7 +156,7 @@
    % if timer_navigation_return_url: - Return to Exam + ${_("Return to Exam")} % endif
    Time Remaining:
     
    @@ -170,9 +171,9 @@
    % if accordion: -
    +
    - close + ${_("close")}
    %endfor @@ -196,7 +196,7 @@
    -

    Current Courses

    +

    ${_("Current Courses")}

    % if len(courses) > 0: @@ -211,11 +211,11 @@ % if course.id in show_courseware_links_for: - ${course.number} ${course.display_name_with_default} Cover Image + ${_( % else:
    - ${course.number} ${course.display_name_with_default} Cover Image + ${_(
    % endif @@ -223,11 +223,11 @@

    % if course.has_ended(): - Course Completed - ${course.end_date_text} + ${_("Course Completed - {end_date}").format(end_date=course.end_date_text)} % elif course.has_started(): - Course Started - ${course.start_date_text} + ${_("Course Started - {start_date}").format(start_date=course.start_date_text)} % else: # hasn't started yet - Course Starts - ${course.start_date_text} + ${_("Course Starts - {start_date}").format(start_date=course.start_date_text)} % endif

    ${get_course_about_section(course, 'university')}

    @@ -249,27 +249,41 @@ % if registration is None and testcenter_exam_info.is_registering():
    - Register for Pearson exam -

    Registration for the Pearson exam is now open and will close on ${testcenter_exam_info.registration_end_date_text}

    + ${_("Register for Pearson exam")} +

    ${_("Registration for the Pearson exam is now open and will close on {end_date}").format(end_date="{}".format(testcenter_exam_info.registration_end_date_text))}

    % endif % if registration is not None: % if registration.is_accepted:
    - Schedule Pearson exam -

    Registration number: ${registration.client_candidate_id}

    -

    Write this down! You’ll need it to schedule your exam.

    + ${_("Schedule Pearson exam")} +

    ${_("{link_start}Registration{link_end} number: {number}").format( + link_start=''.format(url=testcenter_register_target), + link_end='', + number=registration.client_candidate_id, + )}

    +

    ${_("Write this down! You'll need it to schedule your exam.")}

    % endif % if registration.is_rejected:
    -

    Your registration for the Pearson exam has been rejected. Please see your registration status details. Otherwise contact edX at exam-help@edx.org for further help.

    +

    + ${_("Your registration for the Pearson exam has been rejected. Please {link_start}see your registration status details{link_end}.").format( + link_start=''.format(url=testcenter_register_target), + link_end='')} + ${_("Otherwise {link_start}contact edX at {email}{link_end} for further help.').format( + link_start=''.format(email="exam-help@edx.org", about=get_course_about_section(course, 'university'), number=course.number), + link_end='', + email="exam-help@edx.org", + )}

    % endif % if not registration.is_accepted and not registration.is_rejected:
    -

    Your registration for the Pearson exam is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.

    +

    ${_("Your {link_start}registration for the Pearson exam{link_end} is pending.").format(link_start=''.format(url=testcenter_register_target), link_end='')} + ${_("Within a few days, you should see a confirmation number here, which can be used to schedule your exam.")} +

    % endif % endif @@ -292,17 +306,16 @@
    % if cert_status['status'] == 'processing': -

    Final course details are being wrapped up at - this time. Your final standing will be available shortly.

    +

    ${_("Final course details are being wrapped up at this time. Your final standing will be available shortly.")}

    % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'): -

    Your final grade: +

    ${_("Your final grade:")} ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. % if cert_status['status'] == 'notpassing': - Grade required for a certificate: + ${_("Grade required for a certificate:")} ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}. % elif cert_status['status'] == 'restricted':

    - Your certificate is being held pending confirmation that the issuance of your certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting ${settings.CONTACT_EMAIL}. + ${_("Your certificate is being held pending confirmation that the issuance of your certificate is in compliance with strict U.S. embargoes on Iran, Cuba, Syria and Sudan. If you think our system has mistakenly identified you as being connected with one of those countries, please let us know by contacting {email}.").format(email='{email}.'.format(email=settings.CONTACT_EMAIL))}

    % endif

    @@ -312,17 +325,17 @@ % endif @@ -332,12 +345,12 @@ % if course.id in show_courseware_links_for: % if course.has_ended(): - View Archived Course + ${_('View Archived Course')} % else: - View Course + ${_('View Course')} % endif % endif - Unregister + ${_('Unregister')}
    @@ -346,16 +359,16 @@ % endfor % else:
    -

    Looks like you haven't registered for any courses yet.

    +

    ${_("Looks like you haven't registered for any courses yet.")}

    - Find courses now! + ${_("Find courses now!")}
    % endif % if staff_access and len(errored_courses) > 0:
    -

    Course-loading errors

    +

    ${_("Course-loading errors")}

    % for course_dir, errors in errored_courses.items():

    ${course_dir | h}

    @@ -374,7 +387,7 @@