diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 9e41d31c77bea26b43278b34ef269e405ca7770e..5cf21ca68dd9c269b71922fe30d82561e708a6a9 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -4,19 +4,18 @@ import logging import random import re import string +import fnmatch from external_auth.models import ExternalAuthMap from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login -from django.contrib.auth.models import Group from django.contrib.auth.models import User +from student.models import UserProfile -from django.core.urlresolvers import reverse from django.http import HttpResponse, HttpResponseRedirect -from django.shortcuts import render_to_response +from django.utils.http import urlquote from django.shortcuts import redirect -from django.template import RequestContext from mitxmako.shortcuts import render_to_response, render_to_string try: from django.views.decorators.csrf import csrf_exempt @@ -24,100 +23,132 @@ except ImportError: from django.contrib.csrf.middleware import csrf_exempt from django_future.csrf import ensure_csrf_cookie from util.cache import cache_if_anonymous - -from django_openid_auth import auth as openid_auth -from openid.consumer.consumer import (Consumer, SUCCESS, CANCEL, FAILURE) + import django_openid_auth.views as openid_views +from django_openid_auth import auth as openid_auth +from openid.consumer.consumer import SUCCESS + +from openid.server.server import Server +from openid.server.trustroot import TrustRoot +from openid.store.filestore import FileOpenIDStore +from openid.extensions import ax, sreg import student.views as student_views log = logging.getLogger("mitx.external_auth") + +# ----------------------------------------------------------------------------- +# OpenID Common +# ----------------------------------------------------------------------------- + + @csrf_exempt -def default_render_failure(request, message, status=403, template_name='extauth_failure.html', exception=None): - """Render an Openid error page to the user.""" - message = "In openid_failure " + message - log.debug(message) - data = render_to_string( template_name, dict(message=message, exception=exception)) +def default_render_failure(request, + message, + status=403, + template_name='extauth_failure.html', + exception=None): + """Render an Openid error page to the user""" + + log.debug("In openid_failure " + message) + + data = render_to_string(template_name, + dict(message=message, exception=exception)) + return HttpResponse(data, status=status) -#----------------------------------------------------------------------------- -# Openid -def edXauth_generate_password(length=12, chars=string.letters + string.digits): +# ----------------------------------------------------------------------------- +# OpenID Authentication +# ----------------------------------------------------------------------------- + + +def generate_password(length=12, chars=string.letters + string.digits): """Generate internal password for externally authenticated user""" - return ''.join([random.choice(chars) for i in range(length)]) + choice = random.SystemRandom().choice + return ''.join([choice(chars) for i in range(length)]) + @csrf_exempt -def edXauth_openid_login_complete(request, redirect_field_name=REDIRECT_FIELD_NAME, render_failure=None): +def openid_login_complete(request, + redirect_field_name=REDIRECT_FIELD_NAME, + render_failure=None): """Complete the openid login process""" - redirect_to = request.REQUEST.get(redirect_field_name, '') - render_failure = render_failure or \ - getattr(settings, 'OPENID_RENDER_FAILURE', None) or \ - default_render_failure - + render_failure = (render_failure or default_render_failure) + openid_response = openid_views.parse_openid_response(request) if not openid_response: - return render_failure(request, 'This is an OpenID relying party endpoint.') + return render_failure(request, + 'This is an OpenID relying party endpoint.') if openid_response.status == SUCCESS: external_id = openid_response.identity_url - oid_backend = openid_auth.OpenIDBackend() + oid_backend = openid_auth.OpenIDBackend() details = oid_backend._extract_user_details(openid_response) log.debug('openid success, details=%s' % details) - return edXauth_external_login_or_signup(request, - external_id, - "openid:%s" % settings.OPENID_SSO_SERVER_URL, - details, - details.get('email',''), - '%s %s' % (details.get('first_name',''),details.get('last_name','')) - ) - + url = getattr(settings, 'OPENID_SSO_SERVER_URL', None) + external_domain = "openid:%s" % 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 render_failure(request, 'Openid failure') -#----------------------------------------------------------------------------- -# generic external auth login or signup -def edXauth_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 try: - eamap = ExternalAuthMap.objects.get(external_id = external_id, - external_domain = external_domain, - ) + eamap = ExternalAuthMap.objects.get(external_id=external_id, + external_domain=external_domain) log.debug('Found eamap=%s' % eamap) except ExternalAuthMap.DoesNotExist: # go render form for creating edX user - eamap = ExternalAuthMap(external_id = external_id, - external_domain = external_domain, - external_credentials = json.dumps(credentials), - ) + eamap = ExternalAuthMap(external_id=external_id, + external_domain=external_domain, + external_credentials=json.dumps(credentials)) eamap.external_email = email eamap.external_name = fullname - eamap.internal_password = edXauth_generate_password() - log.debug('created eamap=%s' % eamap) + eamap.internal_password = generate_password() + log.debug('Created eamap=%s' % eamap) eamap.save() internal_user = eamap.user if internal_user is None: - log.debug('ExtAuth: no user for %s yet, doing signup' % eamap.external_email) - return edXauth_signup(request, eamap) - + log.debug('No user for %s yet, doing signup' % eamap.external_email) + return signup(request, eamap) + 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 edXauth_signup(request, eamap) + log.warning("External Auth Login failed for %s / %s" % + (uname, eamap.internal_password)) + return signup(request, eamap) if not user.is_active: - log.warning("External Auth: user %s is not active" % (uname)) + log.warning("User %s is not active" % (uname)) # TODO: improve error page - return render_failure(request, 'Account not yet activated: please look for link in your email') - + 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) student_views.try_change_enrollment(request) @@ -125,14 +156,11 @@ def edXauth_external_login_or_signup(request, external_id, external_domain, cred if retfun is None: return redirect('/') return retfun() - - -#----------------------------------------------------------------------------- -# generic external auth signup + @ensure_csrf_cookie @cache_if_anonymous -def edXauth_signup(request, eamap=None): +def signup(request, eamap=None): """ Present form to complete for signup via external authentication. Even though the user has external credentials, he/she still needs @@ -142,32 +170,39 @@ def edXauth_signup(request, eamap=None): eamap is an ExteralAuthMap object, specifying the external user for which to complete the signup. """ - + if eamap is None: pass - request.session['ExternalAuthMap'] = eamap # save this for use by student.views.create_account - + # save this for use by student.views.create_account + request.session['ExternalAuthMap'] = eamap + + # default conjoin name, no spaces + username = eamap.external_name.replace(' ', '') + context = {'has_extauth_info': True, - 'show_signup_immediately' : True, + 'show_signup_immediately': True, 'extauth_email': eamap.external_email, - 'extauth_username' : eamap.external_name.replace(' ',''), # default - conjoin name, no spaces + 'extauth_username': username, 'extauth_name': eamap.external_name, } - - log.debug('ExtAuth: doing signup for %s' % eamap.external_email) + + log.debug('Doing signup for %s' % eamap.external_email) return student_views.index(request, extra_context=context) -#----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- # MIT SSL +# ----------------------------------------------------------------------------- + def ssl_dn_extract_info(dn): - ''' - 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. - ''' + """ + 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) if ss: user = ss.group(1) @@ -181,40 +216,333 @@ def ssl_dn_extract_info(dn): return None return (user, email, fullname) + @csrf_exempt -def edXauth_ssl_login(request): +def ssl_login(request): """ - This is called by student.views.index when MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True + This is called by student.views.index when + MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True - Used for MIT user authentication. This presumes the web server (nginx) has been configured - to require specific client certificates. + Used for MIT user authentication. This presumes the web server + (nginx) has been configured to require specific client + certificates. - If the incoming protocol is HTTPS (SSL) then authenticate via client certificate. - The certificate provides user email and fullname; this populates the ExternalAuthMap. - The user is nevertheless still asked to complete the edX signup. + If the incoming protocol is HTTPS (SSL) then authenticate via + client certificate. The certificate provides user email and + fullname; this populates the ExternalAuthMap. The user is + nevertheless still asked to complete the edX signup. Else continues on with student.views.index, and no authentication. """ - certkey = "SSL_CLIENT_S_DN" # specify the request.META field to use - - cert = request.META.get(certkey,'') + certkey = "SSL_CLIENT_S_DN" # specify the request.META field to use + + cert = request.META.get(certkey, '') if not cert: - cert = request.META.get('HTTP_'+certkey,'') + cert = request.META.get('HTTP_' + certkey, '') if not cert: try: - cert = request._req.subprocess_env.get(certkey,'') # try the direct apache2 SSL key - except Exception as err: - pass + # try the direct apache2 SSL key + cert = request._req.subprocess_env.get(certkey, '') + except Exception: + cert = None + if not cert: # no certificate information - go onward to main index return student_views.index(request) (user, email, fullname) = ssl_dn_extract_info(cert) - - return edXauth_external_login_or_signup(request, - external_id=email, - external_domain="ssl:MIT", - credentials=cert, - email=email, - fullname=fullname, - retfun = functools.partial(student_views.index, request)) + + 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) + + +# ----------------------------------------------------------------------------- +# OpenID Provider +# ----------------------------------------------------------------------------- + + +def get_xrds_url(resource, request): + """ + Return the XRDS url for a resource + """ + host = request.META['HTTP_HOST'] + + if not host.endswith('edx.org'): + return None + + location = host + '/openid/provider/' + resource + '/' + + if request.is_secure(): + return 'https://' + location + else: + return 'http://' + location + + +def add_openid_simple_registration(request, response, data): + sreg_data = {} + sreg_request = sreg.SRegRequest.fromOpenIDRequest(request) + sreg_fields = sreg_request.allRequestedFields() + + # if consumer requested simple registration fields, add them + if sreg_fields: + for field in sreg_fields: + if field == 'email' and 'email' in data: + sreg_data['email'] = data['email'] + elif field == 'fullname' and 'fullname' in data: + sreg_data['fullname'] = data['fullname'] + + # construct sreg response + sreg_response = sreg.SRegResponse.extractResponse(sreg_request, + sreg_data) + sreg_response.toMessage(response.fields) + + +def add_openid_attribute_exchange(request, response, data): + try: + ax_request = ax.FetchRequest.fromOpenIDRequest(request) + except ax.AXError: + # not using OpenID attribute exchange extension + pass + else: + ax_response = ax.FetchResponse() + + # if consumer requested attribute exchange fields, add them + if ax_request and ax_request.requested_attributes: + for type_uri in ax_request.requested_attributes.iterkeys(): + email_schema = 'http://axschema.org/contact/email' + name_schema = 'http://axschema.org/namePerson' + if type_uri == email_schema and 'email' in data: + ax_response.addValue(email_schema, data['email']) + elif type_uri == name_schema and 'fullname' in data: + ax_response.addValue(name_schema, data['fullname']) + + # construct ax response + ax_response.toMessage(response.fields) + + +def provider_respond(server, request, response, data): + """ + Respond to an OpenID request + """ + # get and add extensions + add_openid_simple_registration(request, response, data) + add_openid_attribute_exchange(request, response, data) + + # create http response from OpenID response + webresponse = server.encodeResponse(response) + http_response = HttpResponse(webresponse.body) + http_response.status_code = webresponse.code + + # add OpenID headers to response + for k, v in webresponse.headers.iteritems(): + http_response[k] = v + + return http_response + + +def validate_trust_root(openid_request): + """ + Only allow OpenID requests from valid trust roots + """ + + trusted_roots = getattr(settings, 'OPENID_PROVIDER_TRUSTED_ROOT', None) + + if not trusted_roots: + # not using trusted roots + return True + + # don't allow empty trust roots + if (not hasattr(openid_request, 'trust_root') or + not openid_request.trust_root): + log.error('no trust_root') + return False + + # ensure trust root parses cleanly (one wildcard, of form *.foo.com, etc.) + trust_root = TrustRoot.parse(openid_request.trust_root) + if not trust_root: + log.error('invalid trust_root') + return False + + # don't allow empty return tos + if (not hasattr(openid_request, 'return_to') or + not openid_request.return_to): + log.error('empty return_to') + return False + + # ensure return to is within trust root + if not trust_root.validateURL(openid_request.return_to): + log.error('invalid return_to') + return False + + # check that the root matches the ones we trust + if not any(r for r in trusted_roots if fnmatch.fnmatch(trust_root, r)): + log.error('non-trusted root') + return False + + return True + + +@csrf_exempt +def provider_login(request): + """ + OpenID login endpoint + """ + + # make and validate endpoint + endpoint = get_xrds_url('login', request) + if not endpoint: + return default_render_failure(request, "Invalid OpenID request") + + # initialize store and server + store = FileOpenIDStore('/tmp/openid_provider') + server = Server(store, endpoint) + + # handle OpenID request + querydict = dict(request.REQUEST.items()) + error = False + if 'openid.mode' in request.GET or 'openid.mode' in request.POST: + # decode request + openid_request = server.decodeRequest(querydict) + + if not openid_request: + return default_render_failure(request, "Invalid OpenID request") + + # don't allow invalid and non-trusted trust roots + if not validate_trust_root(openid_request): + return default_render_failure(request, "Invalid OpenID trust root") + + # checkid_immediate not supported, require user interaction + if openid_request.mode == 'checkid_immediate': + return provider_respond(server, openid_request, + openid_request.answer(False), {}) + + # checkid_setup, so display login page + elif openid_request.mode == 'checkid_setup': + if openid_request.idSelect(): + # remember request and original path + request.session['openid_setup'] = { + 'request': openid_request, + 'url': request.get_full_path() + } + + # user failed login on previous attempt + if 'openid_error' in request.session: + error = True + del request.session['openid_error'] + + # OpenID response + else: + return provider_respond(server, openid_request, + server.handleRequest(openid_request), {}) + + # handle login + if request.method == 'POST' and 'openid_setup' in request.session: + # get OpenID request from session + openid_setup = request.session['openid_setup'] + openid_request = openid_setup['request'] + openid_request_url = openid_setup['url'] + del request.session['openid_setup'] + + # don't allow invalid trust roots + if not validate_trust_root(openid_request): + return default_render_failure(request, "Invalid OpenID trust root") + + # check if user with given email exists + email = request.POST.get('email', None) + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + request.session['openid_error'] = True + msg = "OpenID login failed - Unknown user email: {0}".format(email) + log.warning(msg) + return HttpResponseRedirect(openid_request_url) + + # attempt to authenticate user + username = user.username + password = request.POST.get('password', None) + user = authenticate(username=username, password=password) + if user is None: + request.session['openid_error'] = True + msg = "OpenID login failed - password for {0} is invalid" + msg = msg.format(email) + log.warning(msg) + return HttpResponseRedirect(openid_request_url) + + # authentication succeeded, so log user in + if user is not None and user.is_active: + # remove error from session since login succeeded + if 'openid_error' in request.session: + del request.session['openid_error'] + + # fullname field comes from user profile + profile = UserProfile.objects.get(user=user) + log.info("OpenID login success - {0} ({1})".format(user.username, + user.email)) + + # redirect user to return_to location + url = endpoint + urlquote(user.username) + response = openid_request.answer(True, None, url) + + return provider_respond(server, + openid_request, + response, + { + 'fullname': profile.name, + 'email': user.email + }) + + request.session['openid_error'] = True + msg = "Login failed - Account not active for user {0}".format(username) + log.warning(msg) + return HttpResponseRedirect(openid_request_url) + + # determine consumer domain if applicable + return_to = '' + if 'openid.return_to' in request.REQUEST: + return_to = request.REQUEST['openid.return_to'] + matches = re.match(r'\w+:\/\/([\w\.-]+)', return_to) + return_to = matches.group(1) + + # display login page + response = render_to_response('provider_login.html', { + 'error': error, + 'return_to': return_to + }) + + # custom XRDS header necessary for discovery process + response['X-XRDS-Location'] = get_xrds_url('xrds', request) + return response + + +def provider_identity(request): + """ + XRDS for identity discovery + """ + + response = render_to_response('identity.xml', + {'url': get_xrds_url('login', request)}, + mimetype='text/xml') + + # custom XRDS header necessary for discovery process + response['X-XRDS-Location'] = get_xrds_url('identity', request) + return response + + +def provider_xrds(request): + """ + XRDS for endpoint discovery + """ + + response = render_to_response('xrds.xml', + {'url': get_xrds_url('login', request)}, + mimetype='text/xml') + + # custom XRDS header necessary for discovery process + response['X-XRDS-Location'] = get_xrds_url('xrds', request) + return response diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html index e9fd7c5674102847c8eea56dc4ddb01066b5b149..630a3222dc1a529a373e8ed04f925a0428e34812 100644 --- a/common/lib/capa/capa/templates/filesubmission.html +++ b/common/lib/capa/capa/templates/filesubmission.html @@ -1,17 +1,18 @@ <section id="filesubmission_${id}" class="filesubmission"> - <input type="file" name="input_${id}" id="input_${id}" value="${value}" multiple="multiple" data-required_files="${required_files}" data-allowed_files="${allowed_files}"/><br /> + <div class="grader-status file"> % if state == 'unsubmitted': - <span class="unanswered" style="display:inline-block;" id="status_${id}"></span> + <span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span> % elif state == 'correct': - <span class="correct" id="status_${id}"></span> + <span class="correct" id="status_${id}">Correct</span> % elif state == 'incorrect': - <span class="incorrect" id="status_${id}"></span> + <span class="incorrect" id="status_${id}">Incorrect</span> % elif state == 'queued': - <span class="processing" id="status_${id}"></span> + <span class="processing" id="status_${id}">Queued</span> <span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span> % endif - <span style="display:none;" class="debug">(${state})</span> - <br/> - <span class="message">${msg|n}</span> - <br/> + <p class="debug">${state}</p> + + <input type="file" name="input_${id}" id="input_${id}" value="${value}" multiple="multiple" data-required_files="${required_files}" data-allowed_files="${allowed_files}"/> + </div> + <div class="message">${msg|n}</div> </section> diff --git a/common/lib/capa/capa/templates/textbox.html b/common/lib/capa/capa/templates/textbox.html index 19c43482a876f1c9aa7b1d4860490e799c587e17..271d7795e00b4cc0c235edb38c5d058a90730cee 100644 --- a/common/lib/capa/capa/templates/textbox.html +++ b/common/lib/capa/capa/templates/textbox.html @@ -7,26 +7,28 @@ <span id="answer_${id}"></span> - % if state == 'unsubmitted': - <span class="unanswered" style="display:inline-block;" id="status_${id}"></span> - % elif state == 'correct': - <span class="correct" id="status_${id}"></span> - % elif state == 'incorrect': - <span class="incorrect" id="status_${id}"></span> - % elif state == 'queued': - <span class="processing" id="status_${id}"></span> - <span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span> - % endif - % if hidden: - <div style="display:none;" name="${hidden}" inputid="input_${id}" /> - % endif - <br/> - <span style="display:none;" class="debug">(${state})</span> - <br/> - <span class="message">${msg|n}</span> - <br/> + <div class="grader-status"> + % if state == 'unsubmitted': + <span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span> + % elif state == 'correct': + <span class="correct" id="status_${id}">Correct</span> + % elif state == 'incorrect': + <span class="incorrect" id="status_${id}">Incorrect</span> + % elif state == 'queued': + <span class="processing" id="status_${id}">Queued</span> + <span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span> + % endif - <br/> + % if hidden: + <div style="display:none;" name="${hidden}" inputid="input_${id}" /> + % endif + + <p class="debug">${state}</p> + </div> + + <div class="external-grader-message"> + ${msg|n} + </div> <script> // Note: We need to make the area follow the CodeMirror for this to work. @@ -45,12 +47,4 @@ }); }); </script> - <style type="text/css"> - .CodeMirror { - border: 1px solid black; - font-size: 14px; - line-height: 18px; - resize: both; - } - </style> </section> diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index e6ebdb316fbc32a61110e91baa0276a627c6b66c..aea59a4d6367b60fefaa3bede8e4db5979189ee7 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -16,6 +16,7 @@ h2 { } } + section.problem { @media print { display: block; @@ -31,6 +32,7 @@ section.problem { display: inline; } + div { p { &.answer { @@ -171,8 +173,54 @@ section.problem { top: 6px; } } + + .grader-status { + padding: 9px; + background: #F6F6F6; + border: 1px solid #ddd; + border-top: 0; + margin-bottom: 20px; + @include clearfix; + + span { + text-indent: -9999px; + overflow: hidden; + display: block; + float: left; + margin: -7px 7px 0 0; + } + + p { + line-height: 20px; + text-transform: capitalize; + margin-bottom: 0; + float: left; + } + + &.file { + background: #FFF; + margin-top: 20px; + padding: 20px 0 0 0; + + border: { + top: 1px solid #eee; + right: 0; + bottom: 0; + left: 0; + } + + p.debug { + display: none; + } + + input { + float: left; + } + } + } } + ul { list-style: disc outside none; margin-bottom: lh(); @@ -246,6 +294,69 @@ section.problem { } + code { + margin: 0 2px; + padding: 0px 5px; + white-space: nowrap; + border: 1px solid #EAEAEA; + background-color: #F8F8F8; + @include border-radius(3px); + font-size: .9em; + } + + pre { + background-color: #F8F8F8; + border: 1px solid #CCC; + font-size: .9em; + line-height: 1.4; + overflow: auto; + padding: 6px 10px; + @include border-radius(3px); + + > code { + margin: 0; + padding: 0; + white-space: pre; + border: none; + background: transparent; + } + } + + .CodeMirror { + border: 1px solid black; + font-size: 14px; + line-height: 18px; + resize: both; + + pre { + @include border-radius(0); + border-radius: 0; + border-width: 0; + margin: 0; + padding: 0; + background: transparent; + font-family: inherit; + font-size: inherit; + white-space: pre; + word-wrap: normal; + overflow: hidden; + resize: none; + + &.CodeMirror-cursor { + z-index: 10; + position: absolute; + visibility: hidden; + border-left: 1px solid black; + border-right: none; + width: 0; + } + } + } + + .CodeMirror-focused pre.CodeMirror-cursor { + visibility: visible; + } + hr { background: #ddd; border: none; @@ -280,4 +391,96 @@ section.problem { @extend .blue-button; } } + + div.capa_alert { + padding: 8px 12px; + border: 1px solid #EBE8BF; + border-radius: 3px; + background: #FFFCDD; + font-size: 0.9em; + margin-top: 10px; + } + + .hints { + border: 1px solid #ccc; + + h3 { + border-bottom: 1px solid #e3e3e3; + text-shadow: 0 1px 0 #fff; + padding: 9px; + background: #eee; + font-weight: bold; + font-size: em(16); + } + + div { + border-bottom: 1px solid #ddd; + + &:last-child { + border-bottom: none; + } + + p { + margin-bottom: 0; + } + + header { + a { + display: block; + padding: 9px; + background: #F6F6F6; + @include box-shadow(inset 0 0 0 1px #fff); + } + } + + section { + padding: 9px; + } + } + } + + .test { + padding-top: 18px; + + header { + margin-bottom: 12px; + + h3 { + font-size: 0.9em; + font-weight: bold; + font-style: normal; + text-transform: uppercase; + color: #AAA; + } + } + + > section { + border: 1px solid #ddd; + padding: 9px 9px 20px; + margin-bottom: 10px; + background: #FFF; + position: relative; + @include box-shadow(inset 0 0 0 1px #eee); + @include border-radius(3px); + + p:last-of-type { + margin-bottom: 0; + } + + .shortform { + margin-bottom: .6em; + } + + a.full { + @include position(absolute, 0 0 1px 0px); + font-size: .8em; + padding: 4px; + text-align: right; + width: 100%; + display: block; + background: #F3F3F3; + @include box-sizing(border-box); + } + } + } } diff --git a/common/lib/xmodule/xmodule/css/sequence/display.scss b/common/lib/xmodule/xmodule/css/sequence/display.scss index 7b8dc5f57c33f2c97354c32e87e7ff3be5dbd288..25d2c26ddae37e8d66853dd1aaae8d669d7b471c 100644 --- a/common/lib/xmodule/xmodule/css/sequence/display.scss +++ b/common/lib/xmodule/xmodule/css/sequence/display.scss @@ -37,7 +37,6 @@ nav.sequence-nav { height: 44px; margin: 0 30px; @include linear-gradient(top, #ddd, #eee); - overflow: hidden; @include box-shadow(0 1px 3px rgba(0, 0, 0, .1) inset); } diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 0ea6cffb58ef95e8278bd290543c20016110646d..098b79d9cf5f8b6afec57456ccc2a989f4901430 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -263,8 +263,8 @@ class @Problem @el.find('.capa_alert').remove() alert_elem = "<div class='capa_alert'>" + msg + "</div>" @el.find('.action').after(alert_elem) - @el.find('.capa_alert').animate(opacity: 0, 500).animate(opacity: 1, 500) - + @el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700) + save: => Logger.log 'problem_save', @answers $.postWithPrefix "#{@url}/problem_save", @answers, (response) => diff --git a/common/lib/xmodule/xmodule/modulestore/search.py b/common/lib/xmodule/xmodule/modulestore/search.py index baf3d46b570be764adeb26a2519b291f494b4756..f9901e8bfe166c70de7f16d6a7ee86b9059b604e 100644 --- a/common/lib/xmodule/xmodule/modulestore/search.py +++ b/common/lib/xmodule/xmodule/modulestore/search.py @@ -6,15 +6,14 @@ from .exceptions import (ItemNotFoundError, NoPathToItem) from . import ModuleStore, Location -def path_to_location(modulestore, location, course_name=None): +def path_to_location(modulestore, course_id, location): ''' Try to find a course_id/chapter/section[/position] path to location in modulestore. The courseware insists that the first level in the course is chapter, but any kind of module can be a "section". location: something that can be passed to Location - course_name: [optional]. If not None, restrict search to paths - in that course. + course_id: Search for paths in this course. raise ItemNotFoundError if the location doesn't exist. @@ -27,7 +26,7 @@ def path_to_location(modulestore, location, course_name=None): A location may be accessible via many paths. This method may return any valid path. - If the section is a sequence, position will be the position + If the section is a sequential or vertical, position will be the position of this location in that sequence. Otherwise, position will be None. TODO (vshnayder): Not true yet. ''' @@ -41,7 +40,7 @@ def path_to_location(modulestore, location, course_name=None): xs = xs[1] return p - def find_path_to_course(location, course_name=None): + def find_path_to_course(): '''Find a path up the location graph to a node with the specified category. @@ -69,7 +68,8 @@ def path_to_location(modulestore, location, course_name=None): # print 'Processing loc={0}, path={1}'.format(loc, path) if loc.category == "course": - if course_name is None or course_name == loc.name: + # confirm that this is the right course + if course_id == CourseDescriptor.location_to_id(loc): # Found it! path = (loc, path) return flatten(path) @@ -81,17 +81,34 @@ def path_to_location(modulestore, location, course_name=None): # If we're here, there is no path return None - path = find_path_to_course(location, course_name) + path = find_path_to_course() if path is None: - raise(NoPathToItem(location)) + raise NoPathToItem(location) n = len(path) course_id = CourseDescriptor.location_to_id(path[0]) # pull out the location names chapter = path[1].name if n > 1 else None section = path[2].name if n > 2 else None - - # TODO (vshnayder): not handling position at all yet... + # Figure out the position position = None + # This block of code will find the position of a module within a nested tree + # of modules. If a problem is on tab 2 of a sequence that's on tab 3 of a + # sequence, the resulting position is 3_2. However, no positional modules + # (e.g. sequential and videosequence) currently deal with this form of + # representing nested positions. This needs to happen before jumping to a + # module nested in more than one positional module will work. + if n > 3: + position_list = [] + for path_index in range(2, n-1): + category = path[path_index].category + if category == 'sequential' or category == 'videosequence': + section_desc = modulestore.get_instance(course_id, path[path_index]) + child_locs = [c.location for c in section_desc.get_children()] + # positions are 1-indexed, and should be strings to be consistent with + # url parsing. + position_list.append(str(child_locs.index(path[path_index+1]) + 1)) + position = "_".join(position_list) + return (course_id, chapter, section, position) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/__init__.py b/common/lib/xmodule/xmodule/modulestore/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..126f0136e2fe50b661707b75102c63c32ab884ed --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/__init__.py @@ -0,0 +1,12 @@ +from path import path + +# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/modulestore/tests/ +# to ~/mitx_all/mitx/common/test +TEST_DIR = path(__file__).abspath().dirname() +for i in range(5): + TEST_DIR = TEST_DIR.dirname() +TEST_DIR = TEST_DIR / 'test' + +DATA_DIR = TEST_DIR / 'data' + + diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py new file mode 100644 index 0000000000000000000000000000000000000000..c1d1d50a536ce82bb02be5ac658b8e33c483e54a --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py @@ -0,0 +1,34 @@ +from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup + +from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem +from xmodule.modulestore.search import path_to_location + +def check_path_to_location(modulestore): + '''Make sure that path_to_location works: should be passed a modulestore + with the toy and simple courses loaded.''' + should_work = ( + ("i4x://edX/toy/video/Welcome", + ("edX/toy/2012_Fall", "Overview", "Welcome", None)), + ("i4x://edX/toy/chapter/Overview", + ("edX/toy/2012_Fall", "Overview", None, None)), + ) + course_id = "edX/toy/2012_Fall" + + for location, expected in should_work: + assert_equals(path_to_location(modulestore, course_id, location), expected) + + not_found = ( + "i4x://edX/toy/video/WelcomeX", "i4x://edX/toy/course/NotHome" + ) + for location in not_found: + assert_raises(ItemNotFoundError, path_to_location, modulestore, course_id, location) + + # Since our test files are valid, there shouldn't be any + # elements with no path to them. But we can look for them in + # another course. + no_path = ( + "i4x://edX/simple/video/Lost_Video", + ) + for location in no_path: + assert_raises(NoPathToItem, path_to_location, modulestore, course_id, location) + diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 746240e76344a9670d322b48d67c27a07bdc44b9..4c593e391e729f268152c10abaa69be17deb0204 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -1,23 +1,14 @@ import pymongo from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup -from path import path from pprint import pprint from xmodule.modulestore import Location -from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem from xmodule.modulestore.mongo import MongoModuleStore from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.search import path_to_location -# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/modulestore/tests/ -# to ~/mitx_all/mitx/common/test -TEST_DIR = path(__file__).abspath().dirname() -for i in range(5): - TEST_DIR = TEST_DIR.dirname() -TEST_DIR = TEST_DIR / 'test' - -DATA_DIR = TEST_DIR / 'data' +from .test_modulestore import check_path_to_location +from . import DATA_DIR HOST = 'localhost' @@ -110,27 +101,5 @@ class TestMongoModuleStore(object): def test_path_to_location(self): '''Make sure that path_to_location works''' - should_work = ( - ("i4x://edX/toy/video/Welcome", - ("edX/toy/2012_Fall", "Overview", "Welcome", None)), - ("i4x://edX/toy/chapter/Overview", - ("edX/toy/2012_Fall", "Overview", None, None)), - ) - for location, expected in should_work: - assert_equals(path_to_location(self.store, location), expected) - - not_found = ( - "i4x://edX/toy/video/WelcomeX", "i4x://edX/toy/course/NotHome" - ) - for location in not_found: - assert_raises(ItemNotFoundError, path_to_location, self.store, location) - - # Since our test files are valid, there shouldn't be any - # elements with no path to them. But we can look for them in - # another course. - no_path = ( - "i4x://edX/simple/video/Lost_Video", - ) - for location in no_path: - assert_raises(NoPathToItem, path_to_location, self.store, location, "toy") + check_path_to_location(self.store) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py new file mode 100644 index 0000000000000000000000000000000000000000..c4446bebb5da0440677722de5b2345ed502f19e9 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py @@ -0,0 +1,16 @@ +from xmodule.modulestore import Location +from xmodule.modulestore.xml import XMLModuleStore +from xmodule.modulestore.xml_importer import import_from_xml + +from .test_modulestore import check_path_to_location +from . import DATA_DIR + +class TestXMLModuleStore(object): + def test_path_to_location(self): + """Make sure that path_to_location works properly""" + + print "Starting import" + modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'simple']) + print "finished import" + + check_path_to_location(modulestore) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 92eca8f5e6fafba4bd9bc225f0a5dad0cc82e9ad..23a5473292d098b9a463893bd1b790ea76c81d91 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -37,7 +37,7 @@ def clean_out_mako_templating(xml_string): class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): def __init__(self, xmlstore, course_id, course_dir, - policy, error_tracker, **kwargs): + policy, error_tracker, parent_tracker, **kwargs): """ A class that handles loading from xml. Does some munging to ensure that all elements have unique slugs. @@ -79,11 +79,12 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): del attr[key] break - def fallback_name(): + def fallback_name(orig_name=None): """Return the fallback name for this module. This is a function instead of a variable because we want it to be lazy.""" - # use the hash of the content--the first 12 bytes should be plenty. - return tag + "_" + hashlib.sha1(xml).hexdigest()[:12] + # append the hash of the content--the first 12 bytes should be plenty. + orig_name = "_" + orig_name if orig_name is not None else "" + return tag + orig_name + "_" + hashlib.sha1(xml).hexdigest()[:12] # Fallback if there was nothing we could use: if url_name is None or url_name == "": @@ -93,8 +94,9 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): need_uniq_names = ('problem', 'sequence', 'video', 'course', 'chapter') if tag in need_uniq_names: - error_tracker("ERROR: no name of any kind specified for {tag}. Student " - "state won't work right. Problem xml: '{xml}...'".format(tag=tag, xml=xml[:100])) + error_tracker("PROBLEM: no name of any kind specified for {tag}. Student " + "state will not be properly tracked for this module. Problem xml:" + " '{xml}...'".format(tag=tag, xml=xml[:100])) else: # TODO (vshnayder): We may want to enable this once course repos are cleaned up. # (or we may want to give up on the requirement for non-state-relevant issues...) @@ -103,13 +105,20 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # Make sure everything is unique if url_name in self.used_names[tag]: - msg = ("Non-unique url_name in xml. This may break content. url_name={0}. Content={1}" - .format(url_name, xml[:100])) - error_tracker("ERROR: " + msg) + msg = ("Non-unique url_name in xml. This may break state tracking for content." + " url_name={0}. Content={1}".format(url_name, xml[:100])) + error_tracker("PROBLEM: " + msg) log.warning(msg) # Just set name to fallback_name--if there are multiple things with the same fallback name, # they are actually identical, so it's fragile, but not immediately broken. - url_name = fallback_name() + + # TODO (vshnayder): if the tag is a pointer tag, this will + # break the content because we won't have the right link. + # That's also a legitimate attempt to reuse the same content + # from multiple places. Once we actually allow that, we'll + # need to update this to complain about non-unique names for + # definitions, but allow multiple uses. + url_name = fallback_name(url_name) self.used_names[tag].add(url_name) xml_data.set('url_name', url_name) @@ -134,8 +143,8 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): xmlstore.modules[course_id][descriptor.location] = descriptor - if xmlstore.eager: - descriptor.get_children() + for child in descriptor.get_children(): + parent_tracker.add_parent(child.location, descriptor.location) return descriptor render_template = lambda: '' @@ -151,12 +160,51 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): error_tracker, process_xml, policy, **kwargs) +class ParentTracker(object): + """A simple class to factor out the logic for tracking location parent pointers.""" + def __init__(self): + """ + Init + """ + # location -> set(parents). Not using defaultdict because we care about the empty case. + self._parents = dict() + + def add_parent(self, child, parent): + """ + Add a parent of child location to the set of parents. Duplicate calls have no effect. + + child and parent must be something that can be passed to Location. + """ + child = Location(child) + parent = Location(parent) + s = self._parents.setdefault(child, set()) + s.add(parent) + + def is_known(self, child): + """ + returns True iff child has some parents. + """ + child = Location(child) + return child in self._parents + + def make_known(self, location): + """Tell the parent tracker about an object, without registering any + parents for it. Used for the top level course descriptor locations.""" + self._parents.setdefault(location, set()) + + def parents(self, child): + """ + Return a list of the parents of this child. If not is_known(child), will throw a KeyError + """ + child = Location(child) + return list(self._parents[child]) + + class XMLModuleStore(ModuleStoreBase): """ An XML backed ModuleStore """ - def __init__(self, data_dir, default_class=None, eager=False, - course_dirs=None): + def __init__(self, data_dir, default_class=None, course_dirs=None): """ Initialize an XMLModuleStore from data_dir @@ -165,15 +213,11 @@ class XMLModuleStore(ModuleStoreBase): default_class: dot-separated string defining the default descriptor class to use if none is specified in entry_points - eager: If true, load the modules children immediately to force the - entire course tree to be parsed - course_dirs: If specified, the list of course_dirs to load. Otherwise, load all course dirs """ ModuleStoreBase.__init__(self) - self.eager = eager self.data_dir = path(data_dir) self.modules = defaultdict(dict) # course_id -> dict(location -> XModuleDescriptor) self.courses = {} # course_dir -> XModuleDescriptor for the course @@ -186,10 +230,7 @@ class XMLModuleStore(ModuleStoreBase): class_ = getattr(import_module(module_path), class_name) self.default_class = class_ - # TODO (cpennington): We need a better way of selecting specific sets of - # debug messages to enable. These were drowning out important messages - #log.debug('XMLModuleStore: eager=%s, data_dir = %s' % (eager, self.data_dir)) - #log.debug('default_class = %s' % self.default_class) + self.parent_tracker = ParentTracker() # If we are specifically asked for missing courses, that should # be an error. If we are asked for "all" courses, find the ones @@ -221,6 +262,7 @@ class XMLModuleStore(ModuleStoreBase): if course_descriptor is not None: self.courses[course_dir] = course_descriptor self._location_errors[course_descriptor.location] = errorlog + self.parent_tracker.make_known(course_descriptor.location) else: # Didn't load course. Instead, save the errors elsewhere. self.errored_courses[course_dir] = errorlog @@ -339,7 +381,7 @@ class XMLModuleStore(ModuleStoreBase): course_id = CourseDescriptor.make_id(org, course, url_name) - system = ImportSystem(self, course_id, course_dir, policy, tracker) + system = ImportSystem(self, course_id, course_dir, policy, tracker, self.parent_tracker) course_descriptor = system.process_xml(etree.tostring(course_data)) @@ -450,3 +492,19 @@ class XMLModuleStore(ModuleStoreBase): metadata: A nested dictionary of module metadata """ raise NotImplementedError("XMLModuleStores are read-only") + + def get_parent_locations(self, location): + '''Find all locations that are the parents of this location. Needed + for path_to_location(). + + If there is no data at location in this modulestore, raise + ItemNotFoundError. + + returns an iterable of things that can be passed to Location. This may + be empty if there are no parents. + ''' + location = Location.ensure_fully_specified(location) + if not self.parent_tracker.is_known(location): + raise ItemNotFoundError(location) + + return self.parent_tracker.parents(location) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 89f94d8cdb6bbbaaf0dd846fc1e37a7912717a21..be0bdc24c23478de6e0e9b3a09dec81a0b36d58c 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -6,7 +6,7 @@ from .exceptions import DuplicateItemError log = logging.getLogger(__name__) -def import_from_xml(store, data_dir, course_dirs=None, eager=True, +def import_from_xml(store, data_dir, course_dirs=None, default_class='xmodule.raw_module.RawDescriptor'): """ Import the specified xml data_dir into the "store" modulestore, @@ -19,7 +19,6 @@ def import_from_xml(store, data_dir, course_dirs=None, eager=True, module_store = XMLModuleStore( data_dir, default_class=default_class, - eager=eager, course_dirs=course_dirs ) for course_id in module_store.modules.keys(): diff --git a/common/lib/xmodule/xmodule/stringify.py b/common/lib/xmodule/xmodule/stringify.py index dad964140fd3d536cdbf49eb4ad05e82c3944488..1e3fa91210ec3dfbc994287e5c81a543c1bf9246 100644 --- a/common/lib/xmodule/xmodule/stringify.py +++ b/common/lib/xmodule/xmodule/stringify.py @@ -12,9 +12,17 @@ def stringify_children(node): fixed from http://stackoverflow.com/questions/4624062/get-all-text-inside-a-tag-in-lxml ''' - parts = ([node.text] + - list(chain(*([etree.tostring(c), c.tail] - for c in node.getchildren()) - ))) + # Useful things to know: + + # node.tostring() -- generates xml for the node, including start + # and end tags. We'll use this for the children. + # node.text -- the text after the end of a start tag to the start + # of the first child + # node.tail -- the text after the end this tag to the start of the + # next element. + parts = [node.text] + for c in node.getchildren(): + parts.append(etree.tostring(c, with_tail=True)) + # filter removes possible Nones in texts and tails return ''.join(filter(None, parts)) diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index 2520d959370090f3671e54992bc80bf1588a47f0..826e6c9d5abe04598c16b2d7067782d1413b1156 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -49,7 +49,7 @@ class RoundTripTestCase(unittest.TestCase): copytree(data_dir / course_dir, root_dir / course_dir) print "Starting import" - initial_import = XMLModuleStore(root_dir, eager=True, course_dirs=[course_dir]) + initial_import = XMLModuleStore(root_dir, course_dirs=[course_dir]) courses = initial_import.get_courses() self.assertEquals(len(courses), 1) @@ -66,7 +66,7 @@ class RoundTripTestCase(unittest.TestCase): course_xml.write(xml) print "Starting second import" - second_import = XMLModuleStore(root_dir, eager=True, course_dirs=[course_dir]) + second_import = XMLModuleStore(root_dir, course_dirs=[course_dir]) courses2 = second_import.get_courses() self.assertEquals(len(courses2), 1) diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index a36985020926a1a71d1399427b097b924dcf1a15..e81d82bf9ef9f85e5e3cf77e0495b5b71c2acb6a 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -193,7 +193,7 @@ class ImportTestCase(unittest.TestCase): """Make sure that metadata is inherited properly""" print "Starting import" - initial_import = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy']) + initial_import = XMLModuleStore(DATA_DIR, course_dirs=['toy']) courses = initial_import.get_courses() self.assertEquals(len(courses), 1) @@ -216,7 +216,7 @@ class ImportTestCase(unittest.TestCase): def get_course(name): print "Importing {0}".format(name) - modulestore = XMLModuleStore(DATA_DIR, eager=True, course_dirs=[name]) + modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name]) courses = modulestore.get_courses() self.assertEquals(len(courses), 1) return courses[0] @@ -245,7 +245,7 @@ class ImportTestCase(unittest.TestCase): happen--locations should uniquely name definitions. But in our imperfect XML world, it can (and likely will) happen.""" - modulestore = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy', 'two_toys']) + modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'two_toys']) toy_id = "edX/toy/2012_Fall" two_toy_id = "edX/toy/TT_2012_Fall" @@ -261,7 +261,7 @@ class ImportTestCase(unittest.TestCase): """Ensure that colons in url_names convert to file paths properly""" print "Starting import" - modulestore = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy']) + modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy']) courses = modulestore.get_courses() self.assertEquals(len(courses), 1) diff --git a/common/lib/xmodule/xmodule/tests/test_stringify.py b/common/lib/xmodule/xmodule/tests/test_stringify.py index 1c6ee855f393bd8926544774ede5138d83266538..29e99bef566656c713a8b665bdceb921cd5db713 100644 --- a/common/lib/xmodule/xmodule/tests/test_stringify.py +++ b/common/lib/xmodule/xmodule/tests/test_stringify.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equals +from nose.tools import assert_equals, assert_true, assert_false from lxml import etree from xmodule.stringify import stringify_children @@ -8,3 +8,32 @@ def test_stringify(): xml = etree.fromstring(html) out = stringify_children(xml) assert_equals(out, text) + +def test_stringify_again(): + html = """<html name="Voltage Source Answer" >A voltage source is non-linear! +<div align="center"> + <img src="/static/images/circuits/voltage-source.png"/> + \(V=V_C\) + </div> + But it is <a href="http://mathworld.wolfram.com/AffineFunction.html">affine</a>, + which means linear except for an offset. + </html> +""" + + html = """<html>A voltage source is non-linear! + <div align="center"> + + </div> + But it is <a href="http://mathworld.wolfram.com/AffineFunction.html">affine</a>, + which means linear except for an offset. + </html> + """ + xml = etree.fromstring(html) + out = stringify_children(xml) + + print "output:" + print out + + # Tracking strange content repeating bug + # Should appear once + assert_equals(out.count("But it is "), 1) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 0dc16bd976c8e31d34c34f30949e748d398eb488..82f623e9776bd3c20579c63b336a2c1ed698ed6c 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -544,7 +544,13 @@ class XModuleDescriptor(Plugin, HTMLSnippet): # Put import here to avoid circular import errors from xmodule.error_module import ErrorDescriptor msg = "Error loading from xml." - log.warning(msg + " " + str(err)) + log.warning(msg + " " + str(err)[:200]) + + # Normally, we don't want lots of exception traces in our logs from common + # content problems. But if you're debugging the xml loading code itself, + # uncomment the next line. + # log.exception(msg) + system.error_tracker(msg) err_msg = msg + "\n" + exc_info_to_str(sys.exc_info()) descriptor = ErrorDescriptor.from_xml(xml_data, system, org, course, diff --git a/common/test/data/full/chapter/Overview.xml b/common/test/data/full/chapter/Overview.xml index 89917d20da6b5e56ac5421a2a89db851534c8488..a11a11a1e0d1ca2e1228045e103d5a7853395cf9 100644 --- a/common/test/data/full/chapter/Overview.xml +++ b/common/test/data/full/chapter/Overview.xml @@ -1,5 +1,5 @@ <sequential> - <video youtube="0.75:izygArpw-Qo,1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" slug="Welcome" format="Video" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="Welcome"/> + <video url_name="welcome"/> <sequential filename="System_Usage_Sequence" slug="System_Usage_Sequence" format="Lecture Sequence" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="System Usage Sequence"/> <vertical slug="Lab0_Using_the_tools" format="Lab" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="Lab0: Using the tools"> <html slug="html_19" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never"> See the <a href="/section/labintro"> Lab Introduction </a> or <a href="/static/handouts/schematic_tutorial.pdf">Interactive Lab Usage Handout </a> for information on how to do the lab </html> diff --git a/common/test/data/full/video/welcome.xml b/common/test/data/full/video/welcome.xml new file mode 100644 index 0000000000000000000000000000000000000000..762bbeeaf17dc31d891690f9c70d2b211af6d676 --- /dev/null +++ b/common/test/data/full/video/welcome.xml @@ -0,0 +1 @@ +<video youtube="0.75:izygArpw-Qo,1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" format="Video" display_name="Welcome"/> diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index e32eb9213854d5823b18e5066bc2b73de9451495..9fe912e947acb52093ec7d16e0ff3dce0e1321a1 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -20,8 +20,8 @@ def index(request): return redirect(reverse('dashboard')) if settings.MITX_FEATURES.get('AUTH_USE_MIT_CERTIFICATES'): - from external_auth.views import edXauth_ssl_login - return edXauth_ssl_login(request) + from external_auth.views import ssl_login + return ssl_login(request) university = branding.get_university(request.META.get('HTTP_HOST')) if university is None: diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index dbe4ff376d0f55ebee140c308d03ac584e1e8bc4..91c769f90ae2ee363d9b999593c39d9b2e291749 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -321,7 +321,7 @@ def _has_staff_access_to_location(user, location): return True # If not global staff, is the user in the Auth group for this class? - user_groups = [x[1] for x in user.groups.values_list()] + user_groups = [g.name for g in user.groups.all()] staff_group = _course_staff_group_name(location) if staff_group in user_groups: debug("Allow: user in group %s", staff_group) diff --git a/lms/djangoapps/courseware/management/commands/clean_xml.py b/lms/djangoapps/courseware/management/commands/clean_xml.py index 9fd52178a2b8c6f96ee1b4514ecadb505b92c8ec..425dd156c1be96200c8159ace3a87b6581a218f4 100644 --- a/lms/djangoapps/courseware/management/commands/clean_xml.py +++ b/lms/djangoapps/courseware/management/commands/clean_xml.py @@ -57,7 +57,6 @@ def import_with_checks(course_dir, verbose=True): # module. modulestore = XMLModuleStore(data_dir, default_class=None, - eager=True, course_dirs=course_dirs) def str_of_err(tpl): diff --git a/lms/djangoapps/courseware/management/commands/metadata_to_json.py b/lms/djangoapps/courseware/management/commands/metadata_to_json.py index dcbcdc0df33e527f0d22a37dd2947c5e5ae72ec1..8ef0dee7b3b8d917d794c6a544b2cf98389d25ad 100644 --- a/lms/djangoapps/courseware/management/commands/metadata_to_json.py +++ b/lms/djangoapps/courseware/management/commands/metadata_to_json.py @@ -23,7 +23,6 @@ def import_course(course_dir, verbose=True): # module. modulestore = XMLModuleStore(data_dir, default_class=None, - eager=True, course_dirs=course_dirs) def str_of_err(tpl): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 82ec3cadebeeb24f713051924d07417a1b5ba3c5..ee29491d27b10d09d21b4382bf329f09a752a17f 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -195,7 +195,6 @@ def _get_module(user, request, location, student_module_cache, course_id, positi descriptor.category, shared_state_key) - instance_state = instance_module.state if instance_module is not None else None shared_state = shared_module.state if shared_module is not None else None @@ -254,7 +253,7 @@ def _get_module(user, request, location, student_module_cache, course_id, positi # by the replace_static_urls code below replace_urls=replace_urls, node_path=settings.NODE_PATH, - anonymous_student_id=anonymous_student_id + anonymous_student_id=anonymous_student_id, ) # pass position specified in URL to module through ModuleSystem system.set('position', position) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 88e0f53f706c63b1cc135db6567e525447ebae36..56a2384bc5db10a5c9bc46729897d1584cc83039 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -68,7 +68,6 @@ def xml_store_config(data_dir): 'OPTIONS': { 'data_dir': data_dir, 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - 'eager': True, } } } @@ -204,7 +203,8 @@ class PageLoader(ActivateLoginTestCase): self.assertEqual(len(courses), 1) course = courses[0] self.enroll(course) - + course_id = course.id + n = 0 num_bad = 0 all_ok = True @@ -214,7 +214,8 @@ class PageLoader(ActivateLoginTestCase): print "Checking ", descriptor.location.url() #print descriptor.__class__, descriptor.location resp = self.client.get(reverse('jump_to', - kwargs={'location': descriptor.location.url()})) + kwargs={'course_id': course_id, + 'location': descriptor.location.url()})) msg = str(resp.status_code) if resp.status_code != 200: diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 92f67163208c49a6bc35f76e50c319a1708dfc49..6122ebd33335a53a59a0e8337a6c2ab8df8939c2 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -5,8 +5,6 @@ import itertools from functools import partial -from functools import partial - from django.conf import settings from django.core.context_processors import csrf from django.core.urlresolvers import reverse @@ -152,7 +150,7 @@ def index(request, course_id, chapter=None, section=None, course_id, request.user, section_descriptor) module = get_module(request.user, request, section_descriptor.location, - student_module_cache, course_id) + student_module_cache, course_id, position) if module is None: # User is probably being clever and trying to access something # they don't have access to. @@ -196,7 +194,7 @@ def index(request, course_id, chapter=None, section=None, @ensure_csrf_cookie -def jump_to(request, location): +def jump_to(request, course_id, location): ''' Show the page that contains a specific location. @@ -213,15 +211,18 @@ def jump_to(request, location): # Complain if there's not data for this location try: - (course_id, chapter, section, position) = path_to_location(modulestore(), location) + (course_id, chapter, section, position) = path_to_location(modulestore(), course_id, location) except ItemNotFoundError: raise Http404("No data at this location: {0}".format(location)) except NoPathToItem: raise Http404("This location is not in any class: {0}".format(location)) # Rely on index to do all error handling and access control. - return index(request, course_id, chapter, section, position) - + return redirect('courseware_position', + course_id=course_id, + chapter=chapter, + section=section, + position=position) @ensure_csrf_cookie def course_info(request, course_id): """ @@ -328,6 +329,10 @@ def progress(request, course_id, student_id=None): # NOTE: To make sure impersonation by instructor works, use # student instead of request.user in the rest of the function. + # The pre-fetching of groups is done to make auth checks not require an + # additional DB lookup (this kills the Progress page in particular). + student = User.objects.prefetch_related("groups").get(id=student.id) + student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( course_id, student, course) course_module = get_module(student, request, course.location, diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index fd9e53b3b8635071d4b3df652e9df5d6945bd6e6..79911a38bc75c968677c159ffa8b2132f86d922d 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -149,8 +149,8 @@ class HtmlResponse(HttpResponse): def __init__(self, html=''): super(HtmlResponse, self).__init__(html, content_type='text/plain') -class ViewNameMiddleware(object): - def process_view(self, request, view_func, view_args, view_kwargs): +class ViewNameMiddleware(object): + def process_view(self, request, view_func, view_args, view_kwargs): request.view_name = view_func.__name__ class QueryCountDebugMiddleware(object): diff --git a/lms/envs/common.py b/lms/envs/common.py index ce08bf96660810a23207b542a647d535142e7a32..5cd28d24d965fff0941b276689bdc2d6313ac99e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -77,7 +77,7 @@ MITX_FEATURES = { 'ACCESS_REQUIRE_STAFF_FOR_COURSE': False, 'AUTH_USE_OPENID': False, 'AUTH_USE_MIT_CERTIFICATES' : False, - + 'AUTH_USE_OPENID_PROVIDER': False, } # Used for A/B testing @@ -120,6 +120,10 @@ node_paths = [COMMON_ROOT / "static/js/vendor", ] NODE_PATH = ':'.join(node_paths) + +############################ OpenID Provider ################################## +OPENID_PROVIDER_TRUSTED_ROOTS = ['cs50.net', '*.cs50.net'] + ################################## MITXWEB ##################################### # This is where we stick our compiled template files. Most of the app uses Mako # templates @@ -219,7 +223,6 @@ MODULESTORE = { 'OPTIONS': { 'data_dir': DATA_DIR, 'default_class': 'xmodule.hidden_module.HiddenDescriptor', - 'eager': True, } } } diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 5da84f59f061b4c4262d5e5dcd1f3340ed53d4e7..974b8c9fd6e4e0ccc014d0f1b81c7719c4a517c4 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -105,6 +105,7 @@ LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1'] ################################ OpenID Auth ################################# MITX_FEATURES['AUTH_USE_OPENID'] = True +MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True MITX_FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] = True INSTALLED_APPS += ('external_auth',) @@ -115,6 +116,8 @@ OPENID_UPDATE_DETAILS_FROM_SREG = True OPENID_SSO_SERVER_URL = 'https://www.google.com/accounts/o8/id' # TODO: accept more endpoints OPENID_USE_AS_ADMIN_LOGIN = False +OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] + ################################ MIT Certificates SSL Auth ################################# MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True diff --git a/lms/envs/test.py b/lms/envs/test.py index c164889d7987adf0c18745fd18477fcdabc483d6..7cab4cb52c84ec68b1a0f36658518eb4d56cd15d 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -123,6 +123,11 @@ CACHES = { # Dummy secret key for dev SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' +################################## OPENID ###################################### +MITX_FEATURES['AUTH_USE_OPENID'] = True +MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True +OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] + ############################ FILE UPLOADS (ASKBOT) ############################# DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' MEDIA_ROOT = TEST_ROOT / "uploads" diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index 92d19dff86d2b6414c12a5725d6dccc4d4e7a6a5..3f2813ba0ebda9d539d9a3faf0bad08cc6fe5f8b 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -115,6 +115,30 @@ } } + a { + &:hover, &:visited { + text-decoration: none; + } + } + + strong { + @include button(shiny, $blue); + @include box-sizing(border-box); + @include border-radius(3px); + display: block; + float: left; + font: normal 1.2rem/1.6rem $sans-serif; + letter-spacing: 1px; + padding: 10px 0px; + text-transform: uppercase; + text-align: center; + width: flex-grid(3, 8); + + &:hover { + color: rgb(255,255,255); + } + } + span.register { background: lighten($blue, 20%); border: 1px solid $blue; @@ -125,7 +149,10 @@ padding: 10px 0px 8px; text-transform: uppercase; text-align: center; - width: flex-grid(12); + float: left; + margin: 1px flex-gutter(8) 0 0; + @include transition(); + width: flex-grid(5, 8); } } } diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index f37c772aef54b5d3b4a11986c087846d88451e07..53418bc0dda2e2df8ffff8081561d3827f5adb4e 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -261,7 +261,7 @@ padding: 12px 0px; width: 100%; - a.university { + .university { background: rgba(255,255,255, 1); border: 1px solid rgb(180,180,180); @include border-radius(3px); @@ -269,17 +269,14 @@ color: $lighter-base-font-color; display: block; font-style: italic; + font-family: $sans-serif; + font-size: 16px; font-weight: 800; @include inline-block; margin-right: 10px; + margin-bottom: 0; padding: 5px 10px; - float: left; - - &:hover { - color: $blue; - text-decoration: none; - } } h3 { @@ -306,8 +303,12 @@ background: $yellow; border: 1px solid rgb(200,200,200); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); - margin-top: 16px; + margin-top: 17px; + margin-right: flex-gutter(); padding: 5px; + width: flex-grid(8); + float: left; + @include box-sizing(border-box); p { color: $lighter-base-font-color; @@ -317,93 +318,46 @@ } } - .meta { - @include clearfix; - margin-top: 22px; - position: relative; - @include transition(opacity, 0.15s, linear); - width: 100%; - - - .course-work-icon { - @include background-image(url('../images/portal-icons/pencil-icon.png')); - background-size: cover; - float: left; - height: 22px; - opacity: 0.7; - width: 22px; - } - - .complete { - float: right; - - p { - color: $lighter-base-font-color; - font-style: italic; - @include inline-block; - text-align: right; - text-shadow: 0 1px rgba(255,255,255, 0.6); - - .completeness { - color: $base-font-color; - font-weight: 700; - margin-right: 5px; - } - } - } - - .progress { - @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); - left: 35px; - position: absolute; - right: 130px; - - .meter { - background: rgb(245,245,245); - border: 1px solid rgb(160,160,160); - @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.15)); - @include box-sizing(border-box); - @include border-radius(4px); - height: 22px; - margin: 0 auto; - padding: 2px; - width: 100%; - - .meter-fill { - background: $blue; - @include background-image(linear-gradient(-45deg, rgba(255,255,255, 0.15) 25%, - transparent 25%, - transparent 50%, - rgba(255,255,255, 0.15) 50%, - rgba(255,255,255, 0.15) 75%, - transparent 75%)); - background-size: 40px 40px; - background-repeat: repeat-x; - border: 1px solid rgb(115,115,115); - @include border-radius(4px); - @include box-sizing(border-box); - content: ""; - display: block; - height: 100%; - width: 60%; - } - } - } + .enter-course { + @include button(shiny, $blue); + @include box-sizing(border-box); + @include border-radius(3px); + display: block; + float: left; + font: normal 1rem/1.6rem $sans-serif; + letter-spacing: 1px; + padding: 6px 0px; + text-transform: uppercase; + text-align: center; + margin-top: 16px; + width: flex-grid(4); } } - &:hover { + > a:hover { .cover { .shade { background: rgba(255,255,255, 0.1); @include background-image(linear-gradient(-90deg, rgba(255,255,255, 0.3) 0%, - rgba(0,0,0, 0.3) 100%)); + rgba(0,0,0, 0.3) 100%)); } .arrow { opacity: 1; } } + + .info { + background: darken(rgb(250,250,250), 5%); + @include background-image(linear-gradient(-90deg, darken(rgb(253,253,253), 3%), darken(rgb(240,240,240), 5%))); + border-color: darken(rgb(190,190,190), 10%); + + .course-status { + background: darken($yellow, 3%); + border-color: darken(rgb(200,200,200), 3%); + @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); + } + } } } @@ -420,5 +374,6 @@ color: #333; } } + } } diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 770e84984109e7ce7dc7f51034ff8b687e16661c..0f9c26611b5b0fc692aa2d67f684b847cf78dfcf 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -72,30 +72,24 @@ else: course_target = reverse('about_course', args=[course.id]) %> - <a href="${course_target}" class="cover" style="background-image: url('${course_image_url(course)}')"> - <div class="shade"></div> - <div class="arrow">❯</div> - </a> - <section class="info"> - <hgroup> - <a href="${reverse('university_profile', args=[course.org])}" class="university">${get_course_about_section(course, 'university')}</a> - <h3><a href="${course_target}">${course.number} ${course.title}</a></h3> - </hgroup> - <section class="course-status"> - <p>Class Starts - <span>${course.start_date_text}</span></div> + + <a href="${course_target}"> + <section class="cover" style="background-image: url('${course_image_url(course)}')"> + <div class="shade"></div> + <div class="arrow">❯</div> </section> - <section class="meta"> - <div class="course-work-icon"></div> - <div class="progress"> - <div class="meter"> - <div class="meter-fill"></div> - </div> - </div> - <div class="complete"> - ##<p><span class="completeness">60%</span> complete</p> - </div> + + <section class="info"> + <hgroup> + <h2 class="university">${get_course_about_section(course, 'university')}</h2> + <h3>${course.number} ${course.title}</h3> + </hgroup> + <section class="course-status"> + <p>Class Starts - <span>${course.start_date_text}</span></p> + </section> + <p class="enter-course">View Courseware</p> </section> - </section> + </a> </article> <a href="#unenroll-modal" class="unenroll" rel="leanModal" data-course-id="${course.id}" data-course-number="${course.number}">Unregister</a> diff --git a/lms/templates/identity.xml b/lms/templates/identity.xml new file mode 100644 index 0000000000000000000000000000000000000000..a925493c03a2091dc9b19fd99b3564be20855e89 --- /dev/null +++ b/lms/templates/identity.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)"> + <XRD> + <Service priority="0"> + <Type>http://specs.openid.net/auth/2.0/signon</Type> + <Type>http://openid.net/signon/1.1</Type> + <URI>${url}</URI> + </Service> + </XRD> +</xrds:XRDS> diff --git a/lms/templates/portal/course_about.html b/lms/templates/portal/course_about.html index 4931f1fed653a20ffc5668a2ea373fa190b1eac7..bff24d597a10f3062b2a1f9578f210383f84c499 100644 --- a/lms/templates/portal/course_about.html +++ b/lms/templates/portal/course_about.html @@ -74,7 +74,7 @@ %if show_link: <a href="${course_target}"> %endif - <span class="register disabled">You are registered for this course (${course.number}).</span> + <span class="register disabled">You are registered for this course (${course.number})</span> <strong>View Courseware</strong> %if show_link: </a> %endif diff --git a/lms/templates/problem.html b/lms/templates/problem.html index ed49b3bd5d307bcd35230cc3e26b27b559a37d63..65b8193df94ad12c9b6f814c44a8c4d2432c059f 100644 --- a/lms/templates/problem.html +++ b/lms/templates/problem.html @@ -32,3 +32,27 @@ </section> </section> +<%block name="js_extra"> +<script type="text/javascript" charset="utf-8"> + $(function(){ + // this should be brought back into problems + $('.longform').hide(); + $('.shortform').append('<a href="#" class="full">See full output</a>'); + + $('.full').click(function() { + $(this).parent().siblings().slideToggle(); + $(this).parent().parent().toggleClass('open'); + var text = $(this).text() == 'See full output' ? 'Hide output' : 'See full output'; + $(this).text(text); + return false; + }); + + $('.collapsible section').hide() + $('.collapsible header a').click(function() { + $(this).parent().siblings().slideToggle(); + $(this).parent().parent().toggleClass('open'); + return false + }); + }); +</script> +</%block> diff --git a/lms/templates/provider_login.html b/lms/templates/provider_login.html new file mode 100644 index 0000000000000000000000000000000000000000..620e0c41917b4fb9d88c4a8e64547e7366ee20e6 --- /dev/null +++ b/lms/templates/provider_login.html @@ -0,0 +1,52 @@ +<%inherit file="main.html" /> +<%namespace name='static' file='static_content.html'/> + +<%block name="headextra"> +<style type="text/css"> +.openid-login { + display: block; + position: relative; + left: 0; + margin: 100px auto; + top: 0; + z-index: 200; +} + +.openid-login input[type=submit] { + white-space: normal; + height: auto !important; +} + +#lean_overlay { + display: block; + position: fixed; + left: 0px; + top: 0px; + z-index: 100; + width:100%; + height:100%; +} +</style> +</%block> + +<section id="login-modal" class="modal login-modal openid-login"> + <div class="inner-wrapper"> + <header> + <h2>Log In</h2> + <hr> + </header> + <form id="login_form" class="login_form" method="post" action="/openid/provider/login/"> +%if error: + <div id="login_error" class="modal-form-error" style="display: block;">Email or password is incorrect.</div> +%endif + <label>E-mail</label> + <input type="text" name="email" placeholder="E-mail" tabindex="1" /> + <label>Password</label> + <input type="password" name="password" placeholder="Password" tabindex="2" /> + <div class="submit"> + <input name="submit" type="submit" value="Access My Courses and Return To ${return_to}" tabindex="3" /> + </div> + </form> + </div> +</section> +<div id="lean_overlay"></div> diff --git a/lms/templates/xrds.xml b/lms/templates/xrds.xml new file mode 100644 index 0000000000000000000000000000000000000000..2f7713bc8a31b301148d7bf14f8215ec93f4f521 --- /dev/null +++ b/lms/templates/xrds.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)"> + <XRD> + <Service priority="0"> + <Type>http://specs.openid.net/auth/2.0/server</Type> + <Type>http://openid.net/sreg/1.0</Type> + <Type>http://openid.net/srv/ax/1.0</Type> + <URI>${url}</URI> + </Service> + </XRD> +</xrds:XRDS> diff --git a/lms/urls.py b/lms/urls.py index 86d654eb40d73653f1a354588a7c71e40e016b7f..278239751b10645f123cfb82ae050c4f876fbb67 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -98,8 +98,9 @@ if settings.COURSEWARE_ENABLED: urlpatterns += ( # Hook django-masquerade, allowing staff to view site as other users url(r'^masquerade/', include('masquerade.urls')), - url(r'^jump_to/(?P<location>.*)$', 'courseware.views.jump_to', name="jump_to"), + url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/jump_to/(?P<location>.*)$', + 'courseware.views.jump_to', name="jump_to"), url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/modx/(?P<location>.*?)/(?P<dispatch>[^/]*)$', 'courseware.module_render.modx_dispatch', name='modx_dispatch'), @@ -142,6 +143,8 @@ if settings.COURSEWARE_ENABLED: 'courseware.views.index', name="courseware_chapter"), url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/courseware/(?P<chapter>[^/]*)/(?P<section>[^/]*)/$', 'courseware.views.index', name="courseware_section"), + url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/courseware/(?P<chapter>[^/]*)/(?P<section>[^/]*)/(?P<position>[^/]*)/?$', + 'courseware.views.index', name="courseware_position"), url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/progress$', 'courseware.views.progress', name="progress"), # Takes optional student_id for instructor use--shows profile as that student sees it. @@ -164,7 +167,7 @@ if settings.COURSEWARE_ENABLED: if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'): urlpatterns += ( - url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/news$', + url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/news$', 'courseware.views.news', name="news"), url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/discussion/', include('django_comment_client.urls')) @@ -215,9 +218,17 @@ if settings.DEBUG: if settings.MITX_FEATURES.get('AUTH_USE_OPENID'): urlpatterns += ( url(r'^openid/login/$', 'django_openid_auth.views.login_begin', name='openid-login'), - url(r'^openid/complete/$', 'external_auth.views.edXauth_openid_login_complete', name='openid-complete'), + url(r'^openid/complete/$', 'external_auth.views.openid_login_complete', name='openid-complete'), url(r'^openid/logo.gif$', 'django_openid_auth.views.logo', name='openid-logo'), - ) + ) + +if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): + urlpatterns += ( + url(r'^openid/provider/login/$', 'external_auth.views.provider_login', name='openid-provider-login'), + url(r'^openid/provider/login/(?:[\w%\. ]+)$', 'external_auth.views.provider_identity', name='openid-provider-login-identity'), + url(r'^openid/provider/identity/$', 'external_auth.views.provider_identity', name='openid-provider-identity'), + url(r'^openid/provider/xrds/$', 'external_auth.views.provider_xrds', name='openid-provider-xrds') + ) if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += (