Merge remote-tracking branch 'edx/master' into opaque-keys-merge-master

Conflicts:
	cms/djangoapps/contentstore/views/item.py
	cms/djangoapps/contentstore/views/tests/test_container.py
	cms/djangoapps/contentstore/views/tests/test_tabs.py
	common/lib/xmodule/xmodule/modulestore/mongo/draft.py
	lms/djangoapps/certificates/management/commands/gen_cert_report.py
	lms/djangoapps/certificates/queue.py
	lms/djangoapps/certificates/views.py
	lms/djangoapps/courseware/module_render.py
	lms/djangoapps/courseware/tests/test_module_render.py
	lms/djangoapps/instructor/views/api.py
	lms/djangoapps/instructor/views/instructor_dashboard.py
	lms/djangoapps/instructor/views/legacy.py
	lms/djangoapps/shoppingcart/tests/test_models.py
	lms/djangoapps/verify_student/views.py
This commit is contained in:
Calen Pennington
2014-05-09 15:29:32 -04:00
577 changed files with 153911 additions and 12180 deletions

View File

@@ -142,3 +142,4 @@ Marco Re <mrc.re@tiscali.it>
Jonas Jelten <jelten@in.tum.de>
Christine Lytwynec <clytwynec@edx.org>
John Cox <johncox@google.com>
Ben Weeks <benweeks@mit.edu>

View File

@@ -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.
Studio: Add drag-and-drop support to the container page. STUD-1309.
Common: Add extensible third-party auth module.
Blades: Handle situation if no response were sent from XQueue to LMS in Matlab

View File

@@ -2,26 +2,57 @@
Contributing to edx-platform
############################
Contributions to edx-platform are very welcome, and strongly encouraged! The
easiest way is to fork the repo and then make a pull request from your fork.
Check out our `process documentation`_, or read on for details on how to
become a contributor, edx-platform code quality, testing, making a pull
request, and more.
Contributions to edx-platform are very welcome, and strongly encouraged! We've
put together `some documentation that describes our contribution process`_,
but here's a step-by-step guide that should help you get started.
.. _process documentation: https://github.com/edx/edx-platform/blob/master/docs/en_us/developers/source/process/index.rst
.. _some documentation that describes our contribution process: http://edx.readthedocs.org/projects/userdocs/en/latest/process/overview.html
Becoming a Contributor
======================
Step 0: Join the Conversation
=============================
Before your first pull request is merged, you'll need to sign the `individual
contributor agreement`_ and send it in. This confirms you have the authority to
contribute the code in the pull request and ensures we can relicense it.
Got an idea for how to improve the codebase? Fantastic, we'd love to hear about
it! Before you dive in and spend a lot of time and effort making a pull request,
it's a good idea to discuss your idea with other interested developers. You may
get some valuable feedback that changes how you think about your idea, or you
may find other developers who have the same idea and want to work together.
For real-time conversation, we use `IRC`_: we all hang out in the
`#edx-code channel on Freenode`_. Come join us! The channel tends to be most
active Monday through Friday between 13:00 and 21:00 UTC
(9am to 5pm US Eastern time), but interesting conversations can happen
at any time.
.. _IRC: http://www.irchelp.org/
.. _#edx-code channel on Freenode: http://webchat.freenode.net/?channels=edx-code
For asynchronous conversation, we have several mailing lists on Google Groups:
* `openedx-ops`_: everything related to *running* Open edX. This includes
installation issues, server management, cost analysis, and so on.
* `openedx-translation`_: everything related to *translating* Open edX into
other languages. This includes volunteer translators, our internationalization
infrastructure, issues related to Transifex, and so on.
* `edx-code`_: everything related to the *code* in Open edX. This includes
feature requests, idea proposals, refactorings, and so on.
.. _openedx-ops: https://groups.google.com/forum/#!forum/openedx-ops
.. _openedx-translation: https://groups.google.com/forum/#!forum/openedx-translation
.. _edx-code: https://groups.google.com/forum/#!forum/edx-code
Step 1: Sign a Contribution Agreement
=====================================
Before edX can accept any code contributions from you, you'll need to sign
the `individual contributor agreement`_ and send it in. This confirms
that you have the authority to contribute the code in the pull request and
ensures that edX can relicense it.
You should print out the agreement and sign it. Then scan (or photograph) the
signed agreement and email it to the email address indicated on the agreement.
Alternatively, you're also free to physically mail the agreement to the street
address on the agreement. Once we have your agreement in hand, we can begin
merging your work.
reviewing and merging your work.
You'll also need to add yourself to the `AUTHORS` file when you submit your
first pull request. You should add your full name as well as the email address
@@ -31,156 +62,69 @@ request to contain multiple commits, including a commit to `AUTHORS`).
Alternatively, you can open up a separate PR just to have your name added to
the `AUTHORS` file, and link that PR to the PR with your changes.
Step 2: Fork, Commit, and Pull Request
======================================
Github has some great documentation on `how to fork a git repository`_. Once
you've done that, make your changes and `send us a pull request`_! Be sure to
include a detailed description for your pull request, so that a community
manager can understand *what* change you're making, *why* you're making it, *how*
it should work now, and how you can *test* that it's working correctly.
Code Quality Guidelines
=======================
.. _how to fork a git repository: https://help.github.com/articles/fork-a-repo
.. _send us a pull request: https://help.github.com/articles/creating-a-pull-request
Comments
--------
Step 3: Meet PR Requirements
============================
We expect you to contribute code that is self-documenting as much as possible.
This means submitting code with well-formed variable, function, class, and
method names; good docstrings; lots of comments. Use your discretion - not
every line needs to be commented. However, code that is obtuse is hard to
maintain and hard for others to build upon. So please do your best to provide
code that is easy to read and well-commented.
Our `contributor documentation`_ includes a long list of requirements that pull
requests must meet in order to be reviewed by a core committer. These requirements
include things like documentation and passing tests: see the
`contributor documentation`_ page for the full list.
Python/Javascript Styling
-------------------------
.. _contributor documentation: http://edx.readthedocs.org/projects/userdocs/en/latest/process/contributor.html
Before you submit your first pull request, please review the edx-platform code
quality and style guidelines:
Step 4: Approval by Community Manager and Product Owner
=======================================================
* `Python Guidelines <https://github.com/edx/edx-platform/wiki/Python-Guidelines>`_
* `Javascript Guidelines <https://github.com/edx/edx-platform/wiki/Javascript-Guidelines>`_
A community manager will read the description of your pull request. If the
description is understandable, the community manager will send the pull request
to a product owner. The product owner will evaluate if the pull request is a
good idea for Open edX, and if not, your pull request will be rejected. This
is another good reason why you should discuss your ideas with other members
of the community before working on a pull request!
Coding conventions should be followed. Your submission should not introduce any
new pep8 or pylint errors (and ideally, should fix up other errors you
encounter in the files you edit). From the edx-platform main directory, you can
run the command::
Step 5: Code Review by Core Committer(s)
========================================
$ rake quality
If your pull request meets the requirements listed in the
`contributor documentation`_, and it hasn't been rejected by a product owner,
then it will be scheduled for code review by one or more core committers. This
process sometimes takes awhile: currently, all core committers on the project
are employees of edX, and they have to balance their time between code review
and new development.
to print the "Diff Quality" report, a report of the quality violations your
branch has made.
Once the code review process has started, please be responsive to comments on
the pull request, so we can keep the review process moving forward.
If you are unable to respond for a few days, that's fine, but
please add a comment informing us of that -- otherwise, it looks like you're
abandoning your work!
Although we try to be vigilant and resolve all quality violations, some Pylint
violations are just too challenging to resolve, so we opt to ignore them via
use of a pragma. A pragma tells Pylint to ignore the violation in the given
line. An example is::
Step 6: Merge!
==============
self.assertEquals(msg, form._errors['course_id'][0]) # pylint: disable=protected-access
The pragma starts with a ``#`` two spaces after the end of the line. We prefer
that you use the full name of the error (``pylint: disable=unused-argument`` as
opposed to ``pylint: disable=W0613``), so it's more clear what you're disabling
in the line.
If you have any questions, don't hesitate to reach out to us on email or IRC;
see the section on **Contacting Us**, below, for more.
Once the core committers are satisfied that your pull request is ready to go,
one of them will merge it for you. Your code will end up on the edX production
servers in the next release, which usually which happens every week. Congrats!
Testing Coverage Guidelines
===========================
Before you submit a pull request, please refer to the `edx-platform testing
documentation`_.
Code you commit should *increase* test coverage, not decrease it. For more
involved contributions, you may want to discuss your intentions on the mailing
list *before* you start coding.
Running the command ::
$ rake test
in the edx-platform directory will run all the unit tests on edx-platform (to
run specific tests, refer to the testing documentation). Once you've run this
command, you can run ::
$ rake coverage
to generate the "Diff Coverage" report. This report tells you how much of the
Python and JavaScript code you've changed is covered by unit tests. We aim for
a coverage report score of 95% or higher. We also encourage you to write
acceptance tests as your changes require. For more in-depth help on various
types of tests, please refer to the `edx-platform testing documentation`_.
Opening A Pull Request
======================
When you open a pull request (PR), please follow these guidelines:
* In the PR description, please be as clear as possible explaining what the
change is. This helps us so much in contextualizing your PR and providing
appropriate reviewers for you. Take a look at `pull request 1322`_ for an
example of a verbose PR description for a new feature.
* As far as code goes, a first pass is to make sure that your code is of high
quality. This means ensuring plenty of comments, as well as a 100% pass rate
when you run ``rake quality`` locally. See the section **Code Quality
Guidelines**.
* Testing coverage should be as complete as possible. 95% or greater on
JavaScript and Python coverage (you can check this by running ``rake test;
rake coverage`` locally). Percentage coverage is only calculated from unit
tests, however. If you're adding new visual features, we love seeing
acceptance tests as applicable. See the section **Testing Coverage
Guidelines**.
* Be sure that your commit history is *clean* - that is, you don't have a ton
of tiny commits with throwaway commit messages such as "Fix", "Arugh",
"asdfjkl;", "Merge branch Master into fork", etc. Commit messages should be
concise and explain what work was done. The first line should be fewer than
50 characters; you may add additional lines to your commit messages for
further explaination.
* To clean up your commit history you'll need to perform an *interactive
rebase* where you squash your commits together. More about interactive
rebase can be found in the `github help documents`_ or by Googling.
* The reasoning behind a clean commit history is that we want the log of all
commits in edx-platform to be readable and self-documenting. This way,
developers can take a look at all recent commits in the past few days or
weeks and have a good understanding of all the code changes that were made.
* The `CHANGELOG` is a list of changes to the platform, distinct from the git
log because the audience is not developers but rather users of our platform
(specifically, course authors). Please make an entry in `CHANGELOG`
describing your change if it is something that you think platform users would
be interested in - eg a major bugfix, new feature, or update to existing
functionality. Be sure to also indicate what system (LMS, CMS, etc) your
change affects. If in doubt if your change is "big enough", we encourage you
to make a `CHANGELOG` entry!
* Make sure that your branch is freshly rebased on master when you go to open
your pull request. If you don't have repo permissions, you won't be able to
see if your branch is able to be cleanly merged or not. We'll tell you if
it's not; however, rebasing before you open your PR will help decrease the
frequency of conflicts.
* If you need help with rebasing, please see the following resources:
1. `Git Book <http://git-scm.com/book/en/Git-Branching-Rebasing>`_
2. `Git Docs <http://git-scm.com/docs/git-rebase>`_
3. `Interactive Git tutorial <http://pcottle.github.io/learnGitBranching/>`_ -- totally awesome!!
4. `Git Ready <http://gitready.com/intermediate/2009/01/31/intro-to-rebase.html>`_
Finally, **Please Do Not** close a pull request and open a new one to respond
to review comments. Keep the same pull request open, so it's clear how your
code has been worked upon and what reviewers have been involved in the
conversation. Rebase as needed to get updated code from master into your
branch.
Expectations We Have of You
---------------------------
===========================
By opening up a pull request, we expect the following things:
1. You've read and understand the instructions in this contributing file.
1. You've read and understand the instructions in this contributing file and
the contribution process documentation.
2. You are ready to engage with the edX community. Engaging means you will be
prompt in following up with review comments and critiques. Do not open up a
@@ -193,124 +137,21 @@ By opening up a pull request, we expect the following things:
4. If you do not respond to comments on your pull request within 7 days, we
will close it. You are welcome to re-open it when you are ready to engage.
=========================
Expections You Have of Us
-------------------------
=========================
1. Within a week of opening up a pull request, one of our open source community
managers will triage it, either tagging other reviewers for the PR or asking
follow up questions (Please give us a little extra time if you open the PR
on a weekend or around a US holiday! We may take a little longer getting to
it.).
1. Within a week of opening up a pull request, one of our community managers
will triage it, starting the documented contribution process. (Please
give us a little extra time if you open the PR on a weekend or
around a US holiday! We may take a little longer getting to it.)
2. We promise to engage in an active dialogue with you from the time we begin
reviewing until either the PR is merged (by an edX staff member), or we
reviewing until either the PR is merged (by a core committer), or we
decide that, for whatever reason, it should be closed.
3. Once we have determined through visual review that your code is not
malicious, we will run a Jenkins build on your branch.
Using Jenkins Builds
--------------------
When you open up a pull request, an edX staff member can decide to run a
Jenkins build on your branch. We will do this once we have determined that your
code is not malicious.
When a Jenkins job is run, all unit, Javascript, and acceptance tests are run.
**If the build fails...**
Click on the build to be brought to the build page. You'll see a matrix of blue
and red dots; the red dots indicate what section failing tests were present in.
You can click on the test name to be brought to an error trace that explains
why the tests fail. Please address the failing tests before requesting a new
build on your branch. If the failures appear to not have anything to do with
your code, it may be the case that the master branch is failing. You can ask
your reviewers for advice in this scenario.
If the build says "Unstable" but passes all tests, you have introduced too many
pep8 and pylint violations. Please refer to the **Code Quality Guidelines**
section and clean up the code.
**If the build passes...**
If all the tests pass, the "Diff Coverage" and "Diff Quality" reports are
generated. Click on the "View Reports" link on your pull request to be brought
to the Jenkins report page. In a column on the left side of the page are a few
links, including "Diff Coverage Report" and "Diff Quality Report". View each of
these reports (making note that the Diff Quality report has two tabs - one for
pep8, and one for Pylint).
Make sure your quality coverage is 100% and your test coverage is at least 95%.
Adjust your code appropriately if these metrics are not high enough. Be sure to
ask your reviewers for advice if you need it.
Contacting Us
=============
Mailing list
------------
If you have any questions, please ask on the `mailing list`_. It's always a
good idea to first search through the archives, to see if any of your questions
have already been asked and answered.
The edx platform team is based in the US, so we're best able to respond to
questions posted in English. You're most likely to get an answer if you ask
questions related to edx-platform code or conventions. Questions only
tangentially related to edx-platform may be better answered on different forums
or mailing lists (for example, asking for help on how to set up Git is better
posted on a Git related message list or forum).
Questions about translations, creating courses, or using Studio are not
appropriate for the edx-code mailing list. We have a few other mailing lists
you may be interested in:
* `openedx-translation <https://groups.google.com/forum/#!forum/openedx-translation>`_
* `openedx-studio <https://groups.google.com/forum/#!forum/openedx-studio>`_
IRC
---
Many edX employees and community members hang out in the #edx-code `IRC
channel`_ on Freenode. We're always happy to see more people hanging out with
us there!
**Tips on Using IRC**
For clients, the `webchat <http://webchat.freenode.net>`_ is easiest, because you
don't need to install anything and it's cross-platform. `ChatZilla
<http://chatzilla.hacksrus.com/>`_ is almost as easy -- it's a Firefox
extension, and works anywhere Firefox does. For an installed application,
`Pidgin <http://pidgin.im>`_ works decently (or `Adium <https://adium.im>`_ on
Mac), and has a familiar instant-messenger-style interface. For something truly
dedicated to IRC, there's `mIRC <http://www.mirc.com>`_ for Windows (free),
`LimeChat <http://limechat.net/mac/>`_ for Mac (free), or `Textual
<http://www.codeux.com/textual/>`_ for Mac (paid). There are also many other
clients out there, but those are some good recommendations for people
relatively new to IRC.
Pull requests/issues
--------------------
We do not make much use of Github issues, so opening an issue on edx-platform
is not the best way to reach us. However, when you've opened up a pull request,
please please don't be shy about adding comments and having a robust
conversation with your pull request reviewers.
Your pull request is a good place to ask pointed questions about the code
you've written, and we're very happy to have interaction with you through code,
commits, and comments.
.. _individual contributor agreement: http://code.edx.org/individual-contributor-agreement.pdf
.. _edx-platform testing documentation: https://github.com/edx/edx-platform/blob/master/docs/en_us/internal/testing.md
.. _mailing list: https://groups.google.com/forum/#!forum/edx-code
.. _IRC channel: http://www.irchelp.org/irchelp/new2irc.html
.. _pull request 1322: https://github.com/edx/edx-platform/pull/1322
.. _github help documents: https://help.github.com/articles/interactive-rebase

View File

@@ -26,10 +26,10 @@ for details.
Documentation
------------
High-level documentation of the code is located in the `docs` subdirectory.
Most (although not all) of our documentation is built using
Documentation for developers, researchers, and course staff is located in the
`docs` subdirectory. Documentation is built using
[Sphinx](http://sphinx-doc.org/): you can [view the built documentation on
ReadTheDocs](http://edx.readthedocs.org/).
ReadTheDocs](http://docs.edx.org/).
How to Contribute
-----------------

View File

@@ -17,16 +17,16 @@ TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
DELAY = 0.5
ERROR_MESSAGES = {
'url_format': u'Incorrect url format.',
'file_type': u'Link types should be unique.',
'url_format': u'Incorrect URL format.',
'file_type': u'Video file types must be unique.',
}
STATUSES = {
'found': u'Timed Transcript Found',
'not found': u'No Timed Transcript',
'replace': u'Timed Transcript Conflict',
'uploaded_successfully': u'Timed Transcript uploaded successfully',
'use existing': u'Timed Transcript Not Updated',
'uploaded_successfully': u'Timed Transcript Uploaded Successfully',
'use existing': u'Confirm Timed Transcript',
}
SELECTORS = {
@@ -39,11 +39,11 @@ SELECTORS = {
# button type , button css selector, button message
TRANSCRIPTS_BUTTONS = {
'import': ('.setting-import', 'Import from YouTube'),
'download_to_edit': ('.setting-download', 'Download to Edit'),
'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download to Edit'),
'import': ('.setting-import', 'Import YouTube Transcript'),
'download_to_edit': ('.setting-download', 'Download Transcript for Editing'),
'disabled_download_to_edit': ('.setting-download.is-disabled', 'Download Transcript for Editing'),
'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Timed Transcript'),
'replace': ('.setting-replace', 'Yes, Replace EdX Timed Transcript with YouTube Timed Transcript'),
'replace': ('.setting-replace', 'Yes, replace the edX transcript with the YouTube transcript'),
'choose': ('.setting-choose', 'Timed Transcript from {}'),
'use_existing': ('.setting-use-existing', 'Use Existing Timed Transcript'),
}

View File

@@ -240,6 +240,9 @@ def import_handler(request, course_key_string):
# Send errors to client with stage at which error occurred.
except Exception as exception: # pylint: disable=W0703
log.exception(
"error importing course"
)
return JsonResponse(
{
'ErrMsg': str(exception),

View File

@@ -33,6 +33,7 @@ from ..utils import get_modulestore
from .access import has_course_access
from .helpers import _xmodule_recurse
from contentstore.utils import compute_publish_state, PublishState
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from contentstore.views.preview import get_preview_fragment
from edxmako.shortcuts import render_to_string
from models.settings.course_grading import CourseGradingModel
@@ -176,8 +177,14 @@ def xblock_view_handler(request, usage_key_string, view_name):
accept_header = request.META.get('HTTP_ACCEPT', 'application/json')
if 'application/json' in accept_header:
<<<<<<< HEAD
store = get_modulestore(usage_key)
component = store.get_item(usage_key)
=======
store = get_modulestore(old_location)
component = store.get_item(old_location)
is_read_only = _xblock_is_read_only(component)
>>>>>>> edx/master
# wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
@@ -197,12 +204,23 @@ def xblock_view_handler(request, usage_key_string, view_name):
store.update_item(component, None)
elif view_name == 'student_view' and component.has_children:
context = {
'runtime_type': 'studio',
'container_view': False,
'read_only': is_read_only,
'root_xblock': component,
}
# For non-leaf xblocks on the unit page, show the special rendering
# which links to the new container page.
html = render_to_string('container_xblock_component.html', {
'xblock_context': context,
'xblock': component,
<<<<<<< HEAD
'locator': usage_key,
'reordering_enabled': True,
=======
'locator': locator,
>>>>>>> edx/master
})
return JsonResponse({
'html': html,
@@ -210,8 +228,6 @@ def xblock_view_handler(request, usage_key_string, view_name):
})
elif view_name in ('student_view', 'container_preview'):
is_container_view = (view_name == 'container_preview')
component_publish_state = compute_publish_state(component)
is_read_only_view = component_publish_state == PublishState.public
# Only show the new style HTML for the container view, i.e. for non-verticals
# Note: this special case logic can be removed once the unit page is replaced
@@ -219,7 +235,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
context = {
'runtime_type': 'studio',
'container_view': is_container_view,
'read_only': is_read_only_view,
'read_only': is_read_only,
'root_xblock': component,
}
@@ -229,6 +245,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
# into the preview fragment, so we don't want to add another header here.
if not is_container_view:
fragment.content = render_to_string('component.html', {
'xblock_context': context,
'preview': fragment.content,
'label': component.display_name or component.scope_ids.block_type,
})
@@ -248,7 +265,22 @@ def xblock_view_handler(request, usage_key_string, view_name):
return HttpResponse(status=406)
<<<<<<< HEAD
def _save_item(request, usage_key, data=None, children=None, metadata=None, nullout=None,
=======
def _xblock_is_read_only(xblock):
"""
Returns true if the specified xblock is read-only, meaning that it cannot be edited.
"""
# We allow direct editing of xblocks in DIRECT_ONLY_CATEGORIES (for example, static pages).
if xblock.category in DIRECT_ONLY_CATEGORIES:
return False
component_publish_state = compute_publish_state(xblock)
return component_publish_state == PublishState.public
def _save_item(request, usage_loc, item_location, data=None, children=None, metadata=None, nullout=None,
>>>>>>> edx/master
grader_type=None, publish=None):
"""
Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.

View File

@@ -2,10 +2,12 @@
Unit tests for the container view.
"""
import json
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import compute_publish_state, PublishState
from contentstore.views.helpers import xblock_studio_url
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.django import loc_mapper, modulestore
from xmodule.modulestore.tests.factories import ItemFactory
@@ -51,6 +53,7 @@ class ContainerViewTestCase(CourseTestCase):
parent_location=published_xblock_with_child.location,
category="html", display_name="Child HTML"
)
<<<<<<< HEAD
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
expected_breadcrumbs = (
r'<a href="/unit/{unit_location}"\s*'
@@ -62,11 +65,19 @@ class ContainerViewTestCase(CourseTestCase):
unit_location=unicode(self.vertical.location).replace("+", "\\+"),
child_vertical_location=unicode(self.child_vertical.location).replace("+", "\\+"),
)
=======
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
>>>>>>> edx/master
self._test_html_content(
published_xblock_with_child,
expected_location_in_section_tag=published_xblock_with_child.location,
expected_breadcrumbs=expected_breadcrumbs
)
# Now make the unit and its children into a draft and validate the container again
modulestore('draft').convert_to_draft(self.vertical.location)
modulestore('draft').convert_to_draft(self.child_vertical.location)
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
self._test_html_content(
draft_xblock_with_child,
expected_location_in_section_tag=draft_xblock_with_child.location,
@@ -103,3 +114,37 @@ class ContainerViewTestCase(CourseTestCase):
unit_location=unicode(self.vertical.location)
)
self.assertIn(expected_unit_link, html)
def test_container_preview_html(self):
"""
Verify that an xblock returns the expected HTML for a container preview
"""
# First verify that the behavior is correct with a published container
self._test_preview_html(self.vertical)
self._test_preview_html(self.child_vertical)
# Now make the unit and its children into a draft and validate the preview again
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
draft_container = modulestore('draft').convert_to_draft(self.child_vertical.location)
self._test_preview_html(draft_unit)
self._test_preview_html(draft_container)
def _test_preview_html(self, xblock):
"""
Verify that the specified xblock has the expected HTML elements for container preview
"""
locator = loc_mapper().translate_location(self.course.id, xblock.location, published=False)
publish_state = compute_publish_state(xblock)
preview_url = '/xblock/{locator}/container_preview'.format(locator=locator)
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content)
html = resp_content['html']
# Verify that there are no drag handles for public pages
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
if publish_state == PublishState.public:
self.assertNotIn(drag_handle_html, html)
else:
self.assertIn(drag_handle_html, html)

View File

@@ -4,6 +4,7 @@ import json
from contentstore.views import tabs
from contentstore.tests.utils import CourseTestCase
from django.test import TestCase
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.tabs import CourseTabList, WikiTab
from contentstore.utils import reverse_course_url
@@ -23,8 +24,13 @@ class TabsPageTests(CourseTestCase):
self.url = reverse_course_url('tabs_handler', self.course.id)
# add a static tab to the course, for code coverage
<<<<<<< HEAD
ItemFactory.create(
parent_location=self.course.location,
=======
self.test_tab = ItemFactory.create(
parent_location=self.course_location,
>>>>>>> edx/master
category="static_tab",
display_name="Static_1"
)
@@ -173,6 +179,25 @@ class TabsPageTests(CourseTestCase):
)
self.check_invalid_tab_id_response(resp)
def test_tab_preview_html(self):
"""
Verify that the static tab renders itself with the correct HTML
"""
locator = loc_mapper().translate_location(self.course.id, self.test_tab.location)
preview_url = '/xblock/{locator}/student_view'.format(locator=locator)
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content)
html = resp_content['html']
# Verify that the HTML contains the expected elements
self.assertIn('<span class="action-button-text">Edit</span>', html)
self.assertIn('<span class="sr">Duplicate this component</span>', html)
self.assertIn('<span class="sr">Delete this component</span>', html)
self.assertIn('<span data-tooltip="Drag to reorder" class="drag-handle"></span>', html)
class PrimitiveTabEdit(TestCase):
"""Tests for the primitive tab edit data manipulations"""

View File

@@ -251,6 +251,7 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
# Event tracking
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS.update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {})
VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', [])

View File

@@ -260,7 +260,6 @@ SITE_ID = 1
SITE_NAME = "localhost:8001"
HTTPS = 'on'
ROOT_URLCONF = 'cms.urls'
IGNORABLE_404_ENDS = ('favicon.ico')
# Email
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
@@ -546,7 +545,7 @@ COURSES_WITH_UNSAFE_CODE = []
############################## EVENT TRACKING #################################
TRACK_MAX_EVENT = 10000
TRACK_MAX_EVENT = 50000
TRACKING_BACKENDS = {
'logger': {
@@ -557,6 +556,26 @@ TRACKING_BACKENDS = {
}
}
# We're already logging events, and we don't want to capture user
# names/passwords. Heartbeat events are likely not interesting.
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
EVENT_TRACKING_ENABLED = True
EVENT_TRACKING_BACKENDS = {
'logger': {
'ENGINE': 'eventtracking.backends.logger.LoggerBackend',
'OPTIONS': {
'name': 'tracking',
'max_event_size': TRACK_MAX_EVENT,
}
}
}
EVENT_TRACKING_PROCESSORS = [
{
'ENGINE': 'track.shim.LegacyFieldMappingProcessor'
}
]
#### PASSWORD POLICY SETTINGS #####
PASSWORD_MIN_LENGTH = None
@@ -565,11 +584,6 @@ PASSWORD_COMPLEXITY = {}
PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = None
PASSWORD_DICTIONARY = []
# We're already logging events, and we don't want to capture user
# names/passwords. Heartbeat events are likely not interesting.
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
TRACKING_ENABLED = True
##### ACCOUNT LOCKOUT DEFAULT PARAMETERS #####
MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = 5
MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60

View File

@@ -18,6 +18,7 @@ requirejs.config({
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
"jquery.immediateDescendents": "xmodule_js/common_static/coffee/src/jquery.immediateDescendents",
"jquery.simulate": "xmodule_js/common_static/js/vendor/jquery.simulate",
"datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
"date": "xmodule_js/common_static/js/vendor/date",
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
@@ -100,6 +101,10 @@ requirejs.config({
deps: ["jquery"],
exports: "jQuery.fn.inputNumber"
},
"jquery.simulate": {
deps: ["jquery"],
exports: "jQuery.fn.simulate"
},
"jquery.tinymce": {
deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce"
@@ -216,6 +221,7 @@ define([
"js/spec/views/baseview_spec",
"js/spec/views/paging_spec",
"js/spec/views/container_spec",
"js/spec/views/unit_spec",
"js/spec/views/xblock_spec",
"js/spec/views/xblock_editor_spec",

View File

@@ -31,7 +31,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views
)
toggleVisibilityOfTab: (event, ui) =>
checkbox_element = event.srcElement
checkbox_element = event.target
tab_element = $(checkbox_element).parents(".course-tab")[0]
saving = new NotificationView.Mini({title: gettext("Saving&hellip;")})

View File

@@ -1,8 +1,9 @@
define ["jquery", "jquery.ui", "gettext", "backbone",
"js/views/feedback_notification", "js/views/feedback_prompt",
"coffee/src/views/module_edit", "js/models/module_info"],
($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel) ->
class UnitEditView extends Backbone.View
"coffee/src/views/module_edit", "js/models/module_info",
"js/views/baseview"],
($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel, BaseView) ->
class UnitEditView extends BaseView
events:
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates'
'click .new-component .new-component-type a.single-template': 'saveNewComponent'
@@ -212,30 +213,35 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
)
createDraft: (event) ->
@wait(true)
self = this
@disableElementWhileRunning($(event.target), ->
self.wait(true)
$.postJSON(self.model.url(), {
publish: 'create_draft'
}, =>
analytics.track "Created Draft",
course: course_location_analytics
unit_id: unit_location_analytics
$.postJSON(@model.url(), {
publish: 'create_draft'
}, =>
analytics.track "Created Draft",
course: course_location_analytics
unit_id: unit_location_analytics
@model.set('state', 'draft')
self.model.set('state', 'draft')
)
)
publishDraft: (event) ->
@wait(true)
@saveDraft()
self = this
@disableElementWhileRunning($(event.target), ->
self.wait(true)
self.saveDraft()
$.postJSON(@model.url(), {
publish: 'make_public'
}, =>
analytics.track "Published Draft",
course: course_location_analytics
unit_id: unit_location_analytics
$.postJSON(self.model.url(), {
publish: 'make_public'
}, =>
analytics.track "Published Draft",
course: course_location_analytics
unit_id: unit_location_analytics
@model.set('state', 'public')
self.model.set('state', 'public')
)
)
setVisibility: (event) ->
@@ -259,7 +265,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@model.set('state', @$('.visibility-select').val())
)
class UnitEditView.NameEdit extends Backbone.View
class UnitEditView.NameEdit extends BaseView
events:
'change .unit-display-name-input': 'saveName'
@@ -293,14 +299,14 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
display_name: metadata.display_name
class UnitEditView.LocationState extends Backbone.View
class UnitEditView.LocationState extends BaseView
initialize: =>
@model.on('change:state', @render)
render: =>
@$el.toggleClass("#{@model.previous('state')}-item #{@model.get('state')}-item")
class UnitEditView.Visibility extends Backbone.View
class UnitEditView.Visibility extends BaseView
initialize: =>
@model.on('change:state', @render)
@render()

View File

@@ -1,6 +0,0 @@
define(["backbone", "js/models/course_relative"], function(Backbone, CourseRelativeModel) {
var CourseRelativeCollection = Backbone.Collection.extend({
model: CourseRelativeModel
});
return CourseRelativeCollection;
});

View File

@@ -1,9 +0,0 @@
define(["backbone"], function(Backbone) {
var CourseRelative = Backbone.Model.extend({
defaults: {
course_location : null, // must never be null, but here to doc the field
idx : null // the index making it unique in the containing collection (no implied sort)
}
});
return CourseRelative;
});

View File

@@ -76,5 +76,24 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
expect(view.$('.is-collapsible')).not.toHaveClass('collapsed');
});
});
describe("disabled element while running", function() {
it("adds 'is-disabled' class to element while action is running and removes it after", function() {
var viewWithLink,
link,
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView();
setFixtures("<a href='#' id='link'>ripe apples drop about my head</a>");
link = $("#link");
expect(link).not.toHaveClass("is-disabled");
view.disableElementWhileRunning(link, function(){return promise});
expect(link).toHaveClass("is-disabled");
deferred.resolve();
expect(link).not.toHaveClass("is-disabled");
});
});
});
});

View File

@@ -0,0 +1,215 @@
define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers",
"js/views/container", "js/models/xblock_info", "js/views/feedback_notification", "jquery.simulate",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo, Notification) {
describe("Container View", function () {
describe("Supports reordering components", function () {
var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent,
getDragHandle, dragComponentVertically, dragComponentAbove,
verifyRequest, verifyNumReorderCalls, respondToRequest,
rootLocator = 'testCourse/branch/draft/split_test/splitFFF',
containerTestUrl = '/xblock/' + rootLocator,
groupAUrl = "/xblock/locator-group-A",
groupA = "locator-group-A",
groupAComponent1 = "locator-component-A1",
groupAComponent2 = "locator-component-A2",
groupAComponent3 = "locator-component-A3",
groupBUrl = "/xblock/locator-group-B",
groupB = "locator-group-B",
groupBComponent1 = "locator-component-B1",
groupBComponent2 = "locator-component-B2",
groupBComponent3 = "locator-component-B3";
mockContainerHTML = readFixtures('mock/mock-container-xblock.underscore');
respondWithMockXBlockFragment = function (requests, response) {
var requestIndex = requests.length - 1;
create_sinon.respondWithJson(requests, response, requestIndex);
};
beforeEach(function () {
view_helpers.installViewTemplates();
appendSetFixtures('<div class="wrapper-xblock level-page" data-locator="' + rootLocator + '"></div>');
model = new XBlockInfo({
id: rootLocator,
display_name: 'Test AB Test',
category: 'split_test'
});
containerView = new ContainerView({
model: model,
view: 'container_preview',
el: $('.wrapper-xblock')
});
});
afterEach(function () {
containerView.remove();
});
init = function (caller) {
var requests = create_sinon.requests(caller);
containerView.render();
respondWithMockXBlockFragment(requests, {
html: mockContainerHTML,
"resources": []
});
$('body').append(containerView.$el);
return requests;
};
getComponent = function(locator) {
return containerView.$('[data-locator="' + locator + '"]');
};
getDragHandle = function(locator) {
var component = getComponent(locator);
return component.prev();
};
dragComponentVertically = function (locator, dy) {
var handle = getDragHandle(locator);
handle.simulate("drag", {dy: dy});
};
dragComponentAbove = function (sourceLocator, targetLocator) {
var targetElement = getComponent(targetLocator),
targetTop = targetElement.offset().top + 1,
handle = getDragHandle(sourceLocator),
handleY = handle.offset().top + (handle.height() / 2),
dy = targetTop - handleY;
handle.simulate("drag", {dy: dy});
};
verifyRequest = function (requests, reorderCallIndex, expectedURL, expectedChildren) {
var actualIndex, request, children, i;
// 0th call is the response to the initial render call to get HTML.
actualIndex = reorderCallIndex + 1;
expect(requests.length).toBeGreaterThan(actualIndex);
request = requests[actualIndex];
expect(request.url).toEqual(expectedURL);
children = (JSON.parse(request.requestBody)).children;
expect(children.length).toEqual(expectedChildren.length);
for (i = 0; i < children.length; i++) {
expect(children[i]).toEqual(expectedChildren[i]);
}
};
verifyNumReorderCalls = function (requests, expectedCalls) {
// Number of calls will be 1 more than expected because of the initial render call to get HTML.
expect(requests.length).toEqual(expectedCalls + 1);
};
respondToRequest = function (requests, reorderCallIndex, status) {
var actualIndex;
// Number of calls will be 1 more than expected because of the initial render call to get HTML.
actualIndex = reorderCallIndex + 1;
expect(requests.length).toBeGreaterThan(actualIndex);
requests[actualIndex].respond(status);
};
it('does nothing if item not moved far enough', function () {
var requests = init(this);
// Drag the first component in Group A down very slightly but not enough to move it.
dragComponentVertically(groupAComponent1, 5);
verifyNumReorderCalls(requests, 0);
});
it('can reorder within a group', function () {
var requests = init(this);
// Drag the third component in Group A to be the first
dragComponentAbove(groupAComponent3, groupAComponent1);
respondToRequest(requests, 0, 200);
verifyRequest(requests, 0, groupAUrl, [groupAComponent3, groupAComponent1, groupAComponent2]);
});
it('can drag from one group to another', function () {
var requests = init(this);
// Drag the first component in Group B to the top of group A.
dragComponentAbove(groupBComponent1, groupAComponent1);
// Respond to the two requests: add the component to Group A, then remove it from Group B.
respondToRequest(requests, 0, 200);
respondToRequest(requests, 1, 200);
verifyRequest(requests, 0, groupAUrl,
[groupBComponent1, groupAComponent1, groupAComponent2, groupAComponent3]);
verifyRequest(requests, 1, groupBUrl, [groupBComponent2, groupBComponent3]);
});
it('does not remove from old group if addition to new group fails', function () {
var requests = init(this);
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
respondToRequest(requests, 0, 500);
// Send failure for addition to new group -- no removal event should be received.
verifyRequest(requests, 0, groupAUrl,
[groupBComponent1, groupAComponent1, groupAComponent2, groupAComponent3]);
// Verify that a second request was not issued
verifyNumReorderCalls(requests, 1);
});
it('can swap group A and group B', function () {
var requests = init(this);
// Drag Group B before group A.
dragComponentAbove(groupB, groupA);
respondToRequest(requests, 0, 200);
verifyRequest(requests, 0, containerTestUrl, [groupB, groupA]);
});
describe("Shows a saving message", function () {
var savingSpies;
beforeEach(function () {
savingSpies = spyOnConstructor(Notification, "Mini",
["show", "hide"]);
savingSpies.show.andReturn(savingSpies);
});
it('hides saving message upon success', function () {
var requests, savingOptions;
requests = init(this);
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
savingOptions = savingSpies.constructor.mostRecentCall.args[0];
expect(savingOptions.title).toMatch(/Saving/);
respondToRequest(requests, 0, 200);
expect(savingSpies.hide).not.toHaveBeenCalled();
respondToRequest(requests, 1, 200);
expect(savingSpies.hide).toHaveBeenCalled();
});
it('does not hide saving message if failure', function () {
var requests = init(this);
// Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1);
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
respondToRequest(requests, 0, 500);
expect(savingSpies.hide).not.toHaveBeenCalled();
// Since the first reorder call failed, the removal will not be called.
verifyNumReorderCalls(requests, 1);
});
});
});
});
});

View File

@@ -12,7 +12,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
beforeEach(function () {
edit_helpers.installEditTemplates();
appendSetFixtures('<div class="xblock" data-locator="mock-xblock" data-display-name="Mock XBlock"></div>');
appendSetFixtures('<div class="xblock" data-locator="mock-xblock"></div>');
model = new XBlockInfo({
id: 'testCourse/branch/draft/block/verticalFFF',
display_name: 'Test Unit',

View File

@@ -162,5 +162,79 @@ define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/creat
verifyComponents(unit, ['loc_1', 'loc_2']);
});
});
describe("Disabled edit/publish links during ajax call", function() {
var unit,
link,
draft_states = [
{
state: "draft",
selector: ".publish-draft"
},
{
state: "public",
selector: ".create-draft"
}
],
editLinkFixture =
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
<div class="unit-settings window"> \
<h4 class="header">Unit Settings</h4> \
<div class="window-contents"> \
<div class="row published-alert"> \
<p class="edit-draft-message"> \
<a href="#" class="create-draft">edit a draft</a> \
</p> \
<p class="publish-draft-message"> \
<a href="#" class="publish-draft">replace it with this draft</a> \
</p> \
</div> \
</div> \
</div> \
</div>';
function test_link_disabled_during_ajax_call(draft_state) {
beforeEach(function () {
setFixtures(editLinkFixture);
unit = new UnitEditView({
el: $('.main-wrapper'),
model: new ModuleModel({
id: 'unit_locator',
state: draft_state['state']
})
});
// needed to stub out the ajax
window.analytics = jasmine.createSpyObj('analytics', ['track']);
window.course_location_analytics = jasmine.createSpy('course_location_analytics');
window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
});
it("reenables the " + draft_state['selector'] + " link once the ajax call returns", function() {
runs(function(){
spyOn($, "ajax").andCallThrough();
spyOn($.fn, 'addClass').andCallThrough();
spyOn($.fn, 'removeClass').andCallThrough();
link = $(draft_state['selector']);
link.click();
});
waitsFor(function(){
// wait for "is-disabled" to be removed as a class
return !($(draft_state['selector']).hasClass("is-disabled"));
}, 500);
runs(function(){
// check that the `is-disabled` class was added and removed
expect($.fn.addClass).toHaveBeenCalledWith("is-disabled");
expect($.fn.removeClass).toHaveBeenCalledWith("is-disabled");
// make sure the link finishes without the `is-disabled` class
expect(link).not.toHaveClass("is-disabled");
// affirm that ajax was called
expect($.ajax).toHaveBeenCalled();
});
});
};
for (var i = 0; i < draft_states.length; i++) {
test_link_disabled_during_ajax_call(draft_states[i]);
};
});
}
);

View File

@@ -1,8 +1,8 @@
/**
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/
define(["jquery"],
function($) {
define(["jquery", "js/spec_helpers/view_helpers"],
function($, view_helpers) {
var basicModalTemplate = readFixtures('basic-modal.underscore'),
modalButtonTemplate = readFixtures('modal-button.underscore'),
feedbackTemplate = readFixtures('system-feedback.underscore'),
@@ -14,11 +14,7 @@ define(["jquery"],
cancelModalIfShowing;
installModalTemplates = function(append) {
if (append) {
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
} else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
}
view_helpers.installViewTemplates(append);
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate));
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate));
};
@@ -58,11 +54,11 @@ define(["jquery"],
}
};
return {
return $.extend(view_helpers, {
'installModalTemplates': installModalTemplates,
'isShowingModal': isShowingModal,
'hideModalIfShowing': hideModalIfShowing,
'cancelModal': cancelModal,
'cancelModalIfShowing': cancelModalIfShowing
};
});
});

View File

@@ -0,0 +1,20 @@
/**
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/
define(["jquery"],
function($) {
var feedbackTemplate = readFixtures('system-feedback.underscore'),
installViewTemplates;
installViewTemplates = function(append) {
if (append) {
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
} else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
}
};
return {
'installViewTemplates': installViewTemplates
};
});

View File

@@ -1,13 +1,13 @@
define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
function ($, _, Backbone, IframeUtils) {
/*
This view is extended from backbone to provide useful functionality for all Studio views.
This functionality includes:
- automatic expand and collapse of elements with the 'ui-toggle-expansion' class specified
- additional control of rendering by overriding 'beforeRender' or 'afterRender'
This view is extended from backbone to provide useful functionality for all Studio views.
This functionality includes:
- automatic expand and collapse of elements with the 'ui-toggle-expansion' class specified
- additional control of rendering by overriding 'beforeRender' or 'afterRender'
Note: the default 'afterRender' function calls a utility function 'iframeBinding' which modifies
iframe src urls on a page so that they are rendered as part of the DOM.
Note: the default 'afterRender' function calls a utility function 'iframeBinding' which modifies
iframe src urls on a page so that they are rendered as part of the DOM.
*/
var BaseView = Backbone.View.extend({
@@ -60,6 +60,20 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
$('.ui-loading').hide();
},
/**
* Disables a given element when a given operation is running.
* @param {jQuery} element: the element to be disabled.
* @param operation: the operation during whose duration the
* element should be disabled. The operation should return
* a jquery promise.
*/
disableElementWhileRunning: function(element, operation) {
element.addClass("is-disabled");
operation().always(function() {
element.removeClass("is-disabled");
});
},
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.

View File

@@ -0,0 +1,115 @@
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
var ContainerView = XBlockView.extend({
xblockReady: function () {
XBlockView.prototype.xblockReady.call(this);
var verticalContainer = this.$('.vertical-container'),
alreadySortable = this.$('.ui-sortable'),
newParent,
oldParent,
self = this;
alreadySortable.sortable("destroy");
verticalContainer.sortable({
handle: '.drag-handle',
stop: function (event, ui) {
var saving, hideSaving, removeFromParent;
if (oldParent === undefined) {
// If no actual change occurred,
// oldParent will never have been set.
return;
}
saving = new NotificationView.Mini({
title: gettext('Saving&hellip;')
});
saving.show();
hideSaving = function () {
saving.hide();
};
// If moving from one container to another,
// add to new container before deleting from old to
// avoid creating an orphan if the addition fails.
if (newParent) {
removeFromParent = oldParent;
self.reorder(newParent, function () {
self.reorder(removeFromParent, hideSaving);
});
} else {
// No new parent, only reordering within same container.
self.reorder(oldParent, hideSaving);
}
oldParent = undefined;
newParent = undefined;
},
update: function (event, ui) {
// When dragging from one ol to another, this method
// will be called twice (once for each list). ui.sender will
// be null if the change is related to the list the element
// was originally in (the case of a move within the same container
// or the deletion from a container when moving to a new container).
var parent = $(event.target).closest('.wrapper-xblock');
if (ui.sender) {
// Move to a new container (the addition part).
newParent = parent;
} else {
// Reorder inside a container, or deletion when moving to new container.
oldParent = parent;
}
},
helper: "original",
opacity: '0.5',
placeholder: 'component-placeholder',
forcePlaceholderSize: true,
axis: 'y',
items: '> .vertical-element',
connectWith: ".vertical-container",
tolerance: "pointer"
});
},
reorder: function (targetParent, successCallback) {
var children, childLocators;
// Find descendants with class "wrapper-xblock" whose parent == targetParent.
// This is necessary to filter our grandchildren, great-grandchildren, etc.
children = targetParent.find('.wrapper-xblock').filter(function () {
var parent = $(this).parent().closest('.wrapper-xblock');
return parent.data('locator') === targetParent.data('locator');
});
childLocators = _.map(
children,
function (child) {
return $(child).data('locator');
}
);
$.ajax({
url: ModuleUtils.getUpdateUrl(targetParent.data('locator')),
type: 'PUT',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({
children: childLocators
}),
success: function () {
// change data-parent on the element moved.
if (successCallback) {
successCallback();
}
}
});
}
});
return ContainerView;
}); // end define();

View File

@@ -135,13 +135,14 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
var parent = $(event.target.parentElement),
mode = parent.data('mode');
event.preventDefault();
var $cheatsheet = $('.simple-editor-cheatsheet');
if ($cheatsheet.hasClass("shown")) {
$(".CodeMirror").removeAttr("style");
$(".modal-content").removeAttr("style");
$cheatsheet.removeClass('shown');
}
this.selectMode(mode);
var $cheatsheet = $('.simple-editor-cheatsheet');
if ($cheatsheet.length == 0){
$cheatsheet = $('.simple-editor-open-ended-cheatsheet');
}
$(".CodeMirror").css({"overflow": "none"});
$(".modal-content").removeAttr("style");
$cheatsheet.removeClass('shown');
},
selectMode: function(mode) {

View File

@@ -2,8 +2,8 @@
* XBlockContainerView is used to display an xblock which has children, and allows the
* user to interact with the children.
*/
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, NotificationView, PromptView, BaseView, XBlockView, EditXBlockModal, XBlockInfo) {
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/container", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, NotificationView, PromptView, BaseView, ContainerView, XBlockView, EditXBlockModal, XBlockInfo) {
var XBlockContainerView = BaseView.extend({
// takes XBlockInfo as a model
@@ -13,7 +13,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
initialize: function() {
BaseView.prototype.initialize.call(this);
this.noContentElement = this.$('.no-container-content');
this.xblockView = new XBlockView({
this.xblockView = new ContainerView({
el: this.$('.wrapper-xblock'),
model: this.model,
view: this.view
@@ -184,4 +184,3 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
return XBlockContainerView;
}); // end define();

View File

@@ -34,6 +34,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/jquery.min.js
- xmodule_js/common_static/js/vendor/jquery-ui.min.js
- xmodule_js/common_static/js/vendor/jquery.cookie.js
- xmodule_js/common_static/js/vendor/jquery.simulate.js
- xmodule_js/common_static/js/vendor/underscore-min.js
- xmodule_js/common_static/js/vendor/underscore.string.min.js
- xmodule_js/common_static/js/vendor/backbone-min.js

View File

@@ -4,7 +4,7 @@
// basic setup
html {
font-size: 62.5%;
overflow-y: scroll;
height: 102%; // force scrollbar to prevent jump when scroll appears, cannot use overflow because it breaks drag
}
body {

View File

@@ -227,11 +227,12 @@
.action-item {
display: inline-block;
vertical-align: middle;
.action-button {
display: block;
border-radius: 3px;
padding: ($baseline/4) ($baseline/2);
height: ($baseline*1.5);
color: $gray-l1;
&:hover {
@@ -248,6 +249,15 @@
background-color: $gray-l1;
}
}
.drag-handle {
display: block;
float: none;
height: ($baseline*1.2);
width: ($baseline);
margin: 0;
background: transparent url("../img/drag-handles.png") no-repeat right center;
}
}
}

View File

@@ -280,7 +280,8 @@
// ====================
// CASE: user not signed in
.not-signedin {
.not-signedin,
.view-util {
.wrapper-header {

View File

@@ -19,6 +19,7 @@
@include box-sizing(border-box);
@include ui-flexbox();
@extend %ui-align-center-flex;
justify-content: space-between;
border-bottom: 1px solid $gray-l4;
border-radius: ($baseline/5) ($baseline/5) 0 0;
min-height: ($baseline*2.5);
@@ -30,14 +31,14 @@
@extend %ui-justify-left-flex;
@include ui-flexbox();
width: flex-grid(6,12);
vertical-align: top;
vertical-align: middle;
}
.header-actions {
@include ui-flexbox();
@extend %ui-justify-right-flex;
width: flex-grid(6,12);
vertical-align: top;
vertical-align: middle;
}
}
}

View File

@@ -1,7 +1,9 @@
// studio - views - sign up/in
// ====================
.view-signup, .view-signin {
.view-signup,
.view-signin,
.view-util {
.wrapper-content {
margin: ($baseline*1.5) 0 0 0;

View File

@@ -7,7 +7,7 @@
// ====================
// UI: container page view
body.view-container {
.view-container {
.mast {
border-bottom: none;
@@ -97,7 +97,58 @@ body.view-container {
}
// UI: xblock rendering
body.view-container .content-primary {
body.view-container .content-primary {
// dragging bits
.ui-sortable-helper {
article {
display: none;
}
}
.component-placeholder {
height: ($baseline*2.5);
opacity: .5;
margin: $baseline;
background-color: $gray-l5;
border-radius: ($baseline/2);
border: 2px dashed $gray-l2;
}
.vert-mod {
// min-height to allow drop when empty
.vertical-container {
min-height: ($baseline*2.5);
}
.vert {
position: relative;
.drag-handle {
display: none; // only show when vert is draggable
position: absolute;
top: 0;
right: ($baseline/2); // equal to margin on component
width: ($baseline*1.5);
height: ($baseline*2.5);
margin: 0;
background: transparent url("../img/drag-handles.png") no-repeat scroll center center;
}
}
.is-draggable {
.xblock-header {
padding-right: ($baseline*1.5); // make room for drag handle
}
.drag-handle {
display: block;
}
}
}
.wrapper-xblock {
@extend %wrap-xblock;

View File

@@ -1,21 +1,24 @@
<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" />
<%block name="title">${_("Page Not Found")}</%block>
<%block name="bodyclass">view-util util-404</%block>
<%block name="content">
<div class="wrapper-content wrapper">
<section class="content">
<h1>${_("Page not found")}</h1>
<p>${_('The page that you were looking for was not found.')}
<header>
<h1 class="title title-1">${_("Page not found")}</h1>
</header>
<article class="content-primary" role="main">
<p>${_('The page that you were looking for was not found.')}
${_('Go back to the {homepage} or let us know about any pages that may have been moved at {email}.').format(
homepage='<a href="/">homepage</a>',
email=u'<a href="mailto:{address}">{address}</a>'.format(
address=settings.TECH_SUPPORT_EMAIL,
))}
</p>
</p>
</article>
</section>
</div>
</%block>

View File

@@ -1,23 +1,25 @@
<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" />
<%block name="title">${_("Studio Server Error")}</%block>
<%block name="bodyclass">view-util util-500</%block>
<%block name="content">
<div class="wrapper-content wrapper">
<section class="content">
<h1>${_("The <em>Studio</em> servers encountered an error")}</h1>
<p>
${_("An error occurred in Studio and the page could not be loaded. Please try again in a few moments.")}
${_("We've logged the error and our staff is currently working to resolve this error as soon as possible.")}
${_('If the problem persists, please email us at {email_link}.').format(
email_link=u'<a href="mailto:{email_address}">{email_address}</a>'.format(
email_address=settings.TECH_SUPPORT_EMAIL,
)
)}
</p>
<header>
<h1 class="title title-1">${_("The <em>Studio</em> servers encountered an error")}</h1>
</header>
<article class="content-primary" role="main">
<p>
${_("An error occurred in Studio and the page could not be loaded. Please try again in a few moments.")}
${_("We've logged the error and our staff is currently working to resolve this error as soon as possible.")}
${_('If the problem persists, please email us at {email_link}.').format(
email_link=u'<a href="mailto:{email_address}">{email_address}</a>'.format(
email_address=settings.TECH_SUPPORT_EMAIL,
)
)}
</p>
</article>
</section>
</div>
</%block>

View File

@@ -26,6 +26,7 @@
</li>
</ul>
</div>
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% if not xblock_context['read_only']:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
${preview}

View File

@@ -21,8 +21,7 @@ from contentstore.views.helpers import xblock_studio_url
</ul>
</div>
</header>
## We currently support reordering only on the unit page.
% if reordering_enabled:
% if not xblock_context['read_only']:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
</section>

View File

@@ -191,7 +191,7 @@ $('#fileupload').fileupload({
window.onbeforeunload = null;
if (xhr.status != 200) {
if (!result.responseText) {
alert(gettext("Your browser has timed out, but the server is still processing your import. Please wait 5 min and verify that the new content has appeared."));
alert(gettext("Your browser has timed out, but the server is still processing your import. Please wait 5 minutes and verify that the new content has appeared."));
return;
}
var serverMsg = $.parseJSON(result.responseText);

View File

@@ -1,127 +1,222 @@
<header class="xblock-header"></header>
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="xblock" data-block-type="vertical" data-locator="locator-container">
<div class="vert-mod">
<div class="vert vert-0">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-A">
<header class="xblock-header"></header>
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<div class="vert vert-0">
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-A1">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-A">
<header class="xblock-header"></header>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-A2">
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<div class="vert vert-0">
<section class="wrapper-xblock level-element" data-locator="locator-component-A1">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-2">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-A3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
</ol>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-A2">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-A3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</div>
</article>
</section>
</div>
</div>
</article>
</section>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-B">
<header class="xblock-header"></header>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-B">
<header class="xblock-header"></header>
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<ol class="vertical-container">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-B1">
<article class="xblock-render">
<div class="xblock" data-block-type="vertical">
<div class="vert-mod">
<div class="vert vert-0">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-B2">
<section class="wrapper-xblock level-element" data-locator="locator-component-B1">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
<li class="vertical-element is-draggable">
<div class="vert vert-2">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element"
data-locator="locator-component-B3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a
href="#"
class="edit-button action-button"></a>
</li>
<li class="action-item action-duplicate"><a
href="#"
class="duplicate-button action-button"></a>
</li>
<li class="action-item action-delete"><a
href="#"
class="delete-button action-button"></a>
</li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</li>
</ol>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-B2">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
<section class="wrapper-xblock level-element" data-locator="locator-component-B3">
<header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-edit"><a href="#" class="edit-button action-button"></a></li>
<li class="action-item action-duplicate"><a href="#" class="duplicate-button action-button"></a></li>
<li class="action-item action-delete"><a href="#" class="delete-button action-button"></a></li>
</ul>
</div>
</header>
<article class="xblock-render"></article>
</section>
</div>
</div>
</article>
</section>
</div>
</div>
</article>
</section>
</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</li>
</ol>
</div>
</div>
</article>

View File

@@ -4,9 +4,9 @@
</div>
<p class="transcripts-message">
<%= gettext("The timed transcript for the first HTML5 source does not appear to be the same as the timed transcript for the second HTML5 source.") %>
<%= gettext("The timed transcript for the first video file does not appear to be the same as the timed transcript for the second video file.") %>
<strong>
<%= gettext("Which one would you like to use?") %>
<%= gettext("Which timed transcript would you like to use?") %>
</strong>
</p>

View File

@@ -1,16 +1,16 @@
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript Found") %></div>
<p class="transcripts-message">
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
<%= gettext("EdX has a timed transcript for this video. If you want to edit this transcript, you can download, edit, and re-upload the existing transcript. If you want to replace this transcript, upload a new .srt transcript file.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Transcript") %>" data-tooltip="<%= gettext("Upload New .srt Transcript") %>">
<span><%= gettext("Upload New Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span>
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<span><%= gettext("Download Transcript for Editing") %></span>
</a>
</div>

View File

@@ -1,16 +1,16 @@
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No EdX Timed Transcript") %></div>
<p class="transcripts-message">
<%= gettext("We don\'t have a timed transcript for this video on edX, but we found a transcript for this video on YouTube. Would you like to import it to edX?") %>
<%= gettext("EdX doesn\'t have a timed transcript for this video in Studio, but we found a transcript on YouTube. You can import the YouTube transcript or upload your own .srt transcript file.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
<%= gettext("Error.") %>
</p>
<div class="wrapper-transcripts-buttons">
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
<button class="action setting-import" type="button" name="setting-import" value="<%= gettext("Import YouTube Transcript") %>" data-tooltip="<%= gettext("Import YouTube Transcript") %>">
<span><%= gettext("Import YouTube Transcript") %></span>
</button>
<button class="action setting-import" type="button" name="setting-import" value="<%= gettext("Import from YouTube") %>" data-tooltip="<%= gettext("Import from YouTube") %>">
<span><%= gettext("Import from YouTube") %></span>
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Transcript") %>" data-tooltip="<%= gettext("Upload New .srt Transcript") %>">
<span><%= gettext("Upload New Transcript") %></span>
</button>
</div>

View File

@@ -1,6 +1,6 @@
<div class="transcripts-message-status status-error"><i class="icon-remove"></i><%= gettext("No Timed Transcript") %></div>
<p class="transcripts-message">
<%= gettext("We don\'t have a timed transcript for this video. Please upload a .srt file:") %>
<%= gettext("EdX doesn\'t have a timed transcript for this video. Please upload an .srt file.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
@@ -10,7 +10,7 @@
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<%= gettext("Upload New Timed Transcript") %>
</button>
<a class="action setting-download is-disabled" href="javascropt: void(0);" data-tooltip="<%= gettext("Download to Edit") %>">
<%= gettext("Download to Edit") %>
<a class="action setting-download is-disabled" href="javascropt: void(0);" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<%= gettext("Download Transcript for Editing") %>
</a>
</div>

View File

@@ -4,9 +4,9 @@
</div>
<p class="transcripts-message">
<%= gettext("The timed transcript file on YouTube does not appear to be the same as the timed transcript file on edX.") %>
<%= gettext("The timed transcript for this video on edX is out of date, but YouTube has a current timed transcript for this video.") %>
<strong>
<%= gettext("Would you like to replace the edX timed transcript with the ones from YouTube?") %>
<%= gettext("Do you want to replace the edX transcript with the YouTube transcript?") %>
</strong>
</p>
@@ -19,11 +19,11 @@
class="action setting-replace"
type="button"
name="setting-replace"
value="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
data-tooltip="<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>"
value="<%= gettext("Yes, replace the edX transcript with the YouTube transcript") %>"
data-tooltip="<%= gettext("Yes, replace the edX transcript with the YouTube transcript") %>"
>
<span>
<%= gettext("Yes, Replace EdX Timed Transcript with YouTube Timed Transcript") %>
<%= gettext("Yes, replace the edX transcript with the YouTube transcript") %>
</span>
</button>
</div>

View File

@@ -1,6 +1,6 @@
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript uploaded successfully") %></div>
<div class="transcripts-message-status"><i class="icon-ok"></i><%= gettext("Timed Transcript Uploaded Successfully") %></div>
<p class="transcripts-message">
<%= gettext("We have a timed transcript on edX for this video. You can upload a new .srt file to replace it or download to edit.") %>
<%= gettext("EdX has a timed transcript for this video. If you want to replace this transcript, upload a new .srt transcript file. If you want to edit this transcript, you can download, edit, and re-upload the existing transcript.") %>
</p>
<div class="transcripts-file-uploader"></div>
<p class="transcripts-error-message is-invisible">
@@ -10,7 +10,7 @@
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Timed Transcript") %>" data-tooltip="<%= gettext("Upload New Timed Transcript") %>">
<span><%= gettext("Upload New Timed Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download to Edit") %>">
<span><%= gettext("Download to Edit") %></span>
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<span><%= gettext("Download Transcript for Editing") %></span>
</a>
</div>

View File

@@ -1,10 +1,10 @@
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Not Updated") %>
<%= gettext("Confirm Timed Transcript") %>
</div>
<p class="transcripts-message">
<%= gettext("You changed a video source, but did not update the timed transcript file. Do you want to upload new timed transcript?") %>
<%= gettext("You changed a video URL, but did not change the timed transcript file. Do you want to use the current timed transcript or upload a new .srt transcript file?") %>
</p>
<div class="transcripts-file-uploader"></div>
@@ -18,11 +18,11 @@
class="action setting-use-existing"
type="button"
name="setting-use-existing"
value="<%= gettext("Use Existing Timed Transcript") %>"
data-tooltip="<%= gettext("Use Existing Timed Transcript") %>"
value="<%= gettext("Use Current Timed Transcript") %>"
data-tooltip="<%= gettext("Use Current Timed Transcript") %>"
>
<span>
<%= gettext("Use Existing Timed Transcript") %>
<%= gettext("Use Current Timed Transcript") %>
</span>
</button>
<button

View File

@@ -5,10 +5,10 @@
<div class="tip videolist-url-tip setting-help"><%= model.get('help') %></div>
<div class="wrapper-videolist-urls">
<a href="#" class="collapse-action collapse-setting">
<i class="icon-plus"></i><%= gettext("Add more video sources") %> <span class="sr"><%= model.get('display_name')%></span>
<i class="icon-plus"></i><%= gettext("Add URLs for additional versions") %> <span class="sr"><%= model.get('display_name')%></span>
</a>
<div class="videolist-extra-videos">
<span class="tip videolist-extra-videos-tip setting-help"><%= gettext('To be sure all students can view the video, we recommend providing alternate versions of the same video: mp4, webm and youtube (if available).') %></span>
<span class="tip videolist-extra-videos-tip setting-help"><%= gettext('To be sure all students can access the video, we recommend providing both an .mp4 and a .webm version of your video. Click below to add a URL for another version. These URLs cannot be YouTube URLs. The first listed video that\'s compatible with the student\'s computer will play.') %></span>
<ol class="videolist-settings">
<li class="videolist-settings-item">
<input type="text" class="input" value="<%= model.get('value')[1] %>">
@@ -22,6 +22,6 @@
</div>
</div>
<div class="transcripts-status is-invisible">
<label class="label setting-label transcripts-label"><%= gettext("Timed Transcript") %></label>
<label class="label setting-label transcripts-label"><%= gettext("Default Timed Transcript") %></label>
<div class="wrapper-transcripts-message"></div>
</div>

View File

@@ -138,3 +138,9 @@ if settings.DEBUG:
# pylint: disable=C0103
handler404 = 'contentstore.views.render_404'
handler500 = 'contentstore.views.render_500'
# display error page templates, for testing purposes
urlpatterns += (
url(r'404', handler404),
url(r'500', handler500),
)

View File

@@ -10,6 +10,7 @@ in the user's session.
This middleware must be placed before the LocaleMiddleware, but after
the SessionMiddleware.
"""
from django.conf import settings
from django.utils.translation.trans_real import parse_accept_lang_header
@@ -33,6 +34,7 @@ def dark_parse_accept_lang_header(accept):
for lang, priority in browser_langs:
lang = CHINESE_LANGUAGE_CODE_MAP.get(lang.lower(), lang)
django_langs.append((lang, priority))
return django_langs
# If django 1.7 or higher is used, the right-side can be updated with new-style codes.
@@ -65,7 +67,10 @@ class DarkLangMiddleware(object):
"""
Current list of released languages
"""
return DarkLangConfig.current().released_languages_list
language_options = DarkLangConfig.current().released_languages_list
if settings.LANGUAGE_CODE not in language_options:
language_options.append(settings.LANGUAGE_CODE)
return language_options
def process_request(self, request):
"""

View File

@@ -93,6 +93,12 @@ class DarkLangMiddlewareTests(TestCase):
self.process_request(accept='rel;q=1.0, unrel;q=0.5')
)
def test_accept_with_syslang(self):
self.assertAcceptEquals(
'en;q=1.0, rel;q=0.8',
self.process_request(accept='en;q=1.0, rel;q=0.8, unrel;q=0.5')
)
def test_accept_multiple_released_langs(self):
DarkLangConfig(
released_languages=('rel, unrel'),

View File

@@ -34,7 +34,7 @@ class EmbargoedStateAdmin(ConfigurationModelAdmin):
form = EmbargoedStateForm
fieldsets = (
(None, {
'fields': ('embargoed_countries',),
'fields': ('enabled', 'embargoed_countries',),
'description': textwrap.dedent("""Enter the two-letter ISO-3166-1 Alpha-2
code of the country or countries to embargo in the following box. For help,
see <a href="http://en.wikipedia.org/wiki/ISO_3166-1#Officially_assigned_code_elements">
@@ -51,7 +51,7 @@ class IPFilterAdmin(ConfigurationModelAdmin):
form = IPFilterForm
fieldsets = (
(None, {
'fields': ('whitelist', 'blacklist'),
'fields': ('enabled', 'whitelist', 'blacklist'),
'description': textwrap.dedent("""Enter specific IP addresses to explicitly
whitelist (not block) or blacklist (block) in the appropriate box below.
Separate IP addresses with a comma. Do not surround with quotes.

View File

@@ -72,7 +72,7 @@ def _check_caller_authority(caller, role):
:param caller: a user
:param role: an AccessRole
"""
if not (caller.is_authenticated and caller.is_active):
if not (caller.is_authenticated() and caller.is_active):
raise PermissionDenied
# superuser
if GlobalStaff().has_user(caller):

View File

@@ -63,7 +63,7 @@ class Command(BaseCommand):
if '@' in options['user']:
user = User.objects.get(email=options['user'])
else:
user = User.objects.get(user=options['user'])
user = User.objects.get(username=options['user'])
filter_args['user'] = user
enrollments = CourseEnrollment.objects.filter(**filter_args)
if options['noop']:

View File

@@ -10,7 +10,6 @@ file and check it in at the same time as your model changes. To do that,
2. ./manage.py lms schemamigration student --auto description_of_your_change
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
"""
import crum
from datetime import datetime, timedelta
import hashlib
import json
@@ -32,7 +31,6 @@ from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_noop
from django_countries import CountryField
from track import contexts
from track.views import server_track
from eventtracking import tracker
from importlib import import_module
@@ -723,7 +721,7 @@ class CourseEnrollment(models.Model):
}
with tracker.get_tracker().context(event_name, context):
server_track(crum.get_current_request(), event_name, data)
tracker.emit(event_name, data)
except: # pylint: disable=bare-except
if event_name and self.course_id:
log.exception('Unable to emit event %s for user %s and course %s', event_name, self.user.username, self.course_id)

View File

@@ -76,8 +76,10 @@ class CreatorGroupTest(TestCase):
"""
Tests that adding to creator group fails if user is not authenticated
"""
with mock.patch.dict('django.conf.settings.FEATURES',
{'DISABLE_COURSE_CREATION': False, "ENABLE_CREATOR_GROUP": True}):
with mock.patch.dict(
'django.conf.settings.FEATURES',
{'DISABLE_COURSE_CREATION': False, "ENABLE_CREATOR_GROUP": True}
):
anonymous_user = AnonymousUser()
role = CourseCreatorRole()
add_users(self.admin, role, anonymous_user)
@@ -87,8 +89,10 @@ class CreatorGroupTest(TestCase):
"""
Tests that adding to creator group fails if user is not active
"""
with mock.patch.dict('django.conf.settings.FEATURES',
{'DISABLE_COURSE_CREATION': False, "ENABLE_CREATOR_GROUP": True}):
with mock.patch.dict(
'django.conf.settings.FEATURES',
{'DISABLE_COURSE_CREATION': False, "ENABLE_CREATOR_GROUP": True}
):
self.user.is_active = False
add_users(self.admin, CourseCreatorRole(), self.user)
self.assertFalse(has_access(self.user, CourseCreatorRole()))
@@ -108,7 +112,7 @@ class CreatorGroupTest(TestCase):
def test_add_user_to_group_requires_authenticated(self):
with self.assertRaises(PermissionDenied):
self.admin.is_authenticated = False
self.admin.is_authenticated = mock.Mock(return_value=False)
add_users(self.admin, CourseCreatorRole(), self.user)
def test_remove_user_from_group_requires_staff_access(self):
@@ -123,7 +127,7 @@ class CreatorGroupTest(TestCase):
def test_remove_user_from_group_requires_authenticated(self):
with self.assertRaises(PermissionDenied):
self.admin.is_authenticated = False
self.admin.is_authenticated = mock.Mock(return_value=False)
remove_users(self.admin, CourseCreatorRole(), self.user)

View File

@@ -12,17 +12,18 @@ import pytz
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
from django.test.client import RequestFactory
from django.test.client import RequestFactory, Client
from django.contrib.auth.models import User, AnonymousUser
from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse, NoReverseMatch
from django.http import HttpResponse
from unittest.case import SkipTest
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from mock import Mock, patch, sentinel
from mock import Mock, patch
from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user
from student.views import (process_survey_link, _cert_info,
@@ -144,12 +145,58 @@ class DashboardTest(TestCase):
def setUp(self):
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.assertIsNotNone(self.course)
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org")
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test')
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor Code',
)
self.client = Client()
def check_verification_status_on(self, mode, value):
"""
Check that the css class and the status message are in the dashboard html.
"""
CourseEnrollment.enroll(self.user, self.course.location.course_id, mode=mode)
try:
response = self.client.get(reverse('dashboard'))
except NoReverseMatch:
raise SkipTest("Skip this test if url cannot be found (ie running from CMS tests)")
self.assertContains(response, "class=\"course {0}\"".format(mode))
self.assertContains(response, value)
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': True})
def test_verification_status_visible(self):
"""
Test that the certificate verification status for courses is visible on the dashboard.
"""
self.client.login(username="jack", password="test")
self.check_verification_status_on('verified', 'You\'re enrolled as a verified student')
self.check_verification_status_on('honor', 'You\'re enrolled as an honor code student')
self.check_verification_status_on('audit', 'You\'re auditing this course')
def check_verification_status_off(self, mode, value):
"""
Check that the css class and the status message are not in the dashboard html.
"""
CourseEnrollment.enroll(self.user, self.course.location.course_id, mode=mode)
try:
response = self.client.get(reverse('dashboard'))
except NoReverseMatch:
raise SkipTest("Skip this test if url cannot be found (ie running from CMS tests)")
self.assertNotContains(response, "class=\"course {0}\"".format(mode))
self.assertNotContains(response, value)
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': False})
def test_verification_status_invisible(self):
"""
Test that the certificate verification status for courses is not visible on the dashboard
if the verified certificates setting is off.
"""
self.client.login(username="jack", password="test")
self.check_verification_status_off('verified', 'You\'re enrolled as a verified student')
self.check_verification_status_off('honor', 'You\'re enrolled as an honor code student')
self.check_verification_status_off('audit', 'You\'re auditing this course')
def test_course_mode_info(self):
verified_mode = CourseModeFactory.create(
@@ -190,15 +237,10 @@ class EnrollInCourseTest(TestCase):
"""Tests enrolling and unenrolling in courses."""
def setUp(self):
patcher = patch('student.models.server_track')
self.mock_server_track = patcher.start()
patcher = patch('student.models.tracker')
self.mock_tracker = patcher.start()
self.addCleanup(patcher.stop)
crum_patcher = patch('student.models.crum.get_current_request')
self.mock_get_current_request = crum_patcher.start()
self.addCleanup(crum_patcher.stop)
self.mock_get_current_request.return_value = sentinel.request
def test_enrollment(self):
user = User.objects.create_user("joe", "joe@joe.com", "password")
course_id = SlashSeparatedCourseKey("edX", "Test101", "2013")
@@ -247,13 +289,12 @@ class EnrollInCourseTest(TestCase):
def assert_no_events_were_emitted(self):
"""Ensures no events were emitted since the last event related assertion"""
self.assertFalse(self.mock_server_track.called)
self.mock_server_track.reset_mock()
self.assertFalse(self.mock_tracker.emit.called) # pylint: disable=maybe-no-member
self.mock_tracker.reset_mock()
def assert_enrollment_event_was_emitted(self, user, course_key):
"""Ensures an enrollment event was emitted since the last event related assertion"""
self.mock_server_track.assert_called_once_with(
sentinel.request,
self.mock_tracker.emit.assert_called_once_with( # pylint: disable=maybe-no-member
'edx.course.enrollment.activated',
{
'course_id': course_key.to_deprecated_string(),
@@ -261,12 +302,11 @@ class EnrollInCourseTest(TestCase):
'mode': 'honor'
}
)
self.mock_server_track.reset_mock()
self.mock_tracker.reset_mock()
def assert_unenrollment_event_was_emitted(self, user, course_key):
"""Ensures an unenrollment event was emitted since the last event related assertion"""
self.mock_server_track.assert_called_once_with(
sentinel.request,
self.mock_tracker.emit.assert_called_once_with( # pylint: disable=maybe-no-member
'edx.course.enrollment.deactivated',
{
'course_id': course_key.to_deprecated_string(),
@@ -274,7 +314,7 @@ class EnrollInCourseTest(TestCase):
'mode': 'honor'
}
)
self.mock_server_track.reset_mock()
self.mock_tracker.reset_mock()
def test_enrollment_non_existent_user(self):
# Testing enrollment of newly unsaved user (i.e. no database entry)
@@ -438,8 +478,8 @@ class AnonymousLookupTable(TestCase):
mode_slug='honor',
mode_display_name='Honor Code',
)
patcher = patch('student.models.server_track')
self.mock_server_track = patcher.start()
patcher = patch('student.models.tracker')
patcher.start()
self.addCleanup(patcher.stop)
def test_for_unregistered_user(self): # same path as for logged out user

View File

@@ -2,10 +2,8 @@
Student Views
"""
import datetime
import json
import logging
import re
import urllib
import uuid
import time
from collections import defaultdict
@@ -17,7 +15,6 @@ from django.contrib.auth.models import User, AnonymousUser
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import password_reset_confirm
from django.contrib import messages
from django.core.cache import cache
from django.core.context_processors import csrf
from django.core.mail import send_mail
from django.core.urlresolvers import reverse
@@ -92,7 +89,6 @@ from third_party_auth import pipeline, provider
log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
Article = namedtuple('Article', 'title url author image deck publication publish_date')
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=C0103
def csrf_token(context):
@@ -135,19 +131,6 @@ def course_from_id(course_id):
"""Return the CourseDescriptor corresponding to this course_id"""
return modulestore().get_course(course_id)
day_pattern = re.compile(r'\s\d+,\s')
multimonth_pattern = re.compile(r'\s?\-\s?\S+\s')
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):
date = datetime.datetime.strptime(date, "%B %d, %Y").replace(tzinfo=UTC)
else:
date = datetime.datetime.strptime(date, "%B, %Y").replace(tzinfo=UTC)
return date
def embargo(_request):
"""
@@ -165,18 +148,7 @@ def embargo(_request):
def press(request):
json_articles = cache.get("student_press_json_articles")
if json_articles is None:
if hasattr(settings, 'RSS_URL'):
content = urllib.urlopen(settings.PRESS_URL).read()
json_articles = json.loads(content)
else:
content = open(settings.PROJECT_ROOT / "templates" / "press.json").read()
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)
return render_to_response('static_templates/press.html', {'articles': articles})
return render_to_response('static_templates/press.html')
def process_survey_link(survey_link, user):
@@ -200,7 +172,7 @@ def cert_info(user, course):
'survey_url': url, only if show_survey_button is True
'grade': if status is not 'processing'
"""
if not course.has_ended():
if not course.may_certify():
return {}
return _cert_info(user, course, certificate_status_for_student(user, course.id))
@@ -291,6 +263,15 @@ def _cert_info(user, course, cert_status):
"""
Implements the logic for cert_info -- split out for testing.
"""
# simplify the status for the template using this lookup table
template_state = {
CertificateStatuses.generating: 'generating',
CertificateStatuses.regenerating: 'generating',
CertificateStatuses.downloadable: 'ready',
CertificateStatuses.notpassing: 'notpassing',
CertificateStatuses.restricted: 'restricted',
}
default_status = 'processing'
default_info = {'status': default_status,
@@ -302,15 +283,6 @@ def _cert_info(user, course, cert_status):
if cert_status is None:
return default_info
# simplify the status for the template using this lookup table
template_state = {
CertificateStatuses.generating: 'generating',
CertificateStatuses.regenerating: 'generating',
CertificateStatuses.downloadable: 'ready',
CertificateStatuses.notpassing: 'notpassing',
CertificateStatuses.restricted: 'restricted',
}
status = template_state.get(cert_status['status'], default_status)
d = {'status': status,

View File

@@ -13,7 +13,7 @@ from uuid import uuid4
import textwrap
import urllib
import re
from oauthlib.oauth1.rfc5849 import signature
from oauthlib.oauth1.rfc5849 import signature, parameters
import oauthlib.oauth1
import hashlib
import base64
@@ -46,7 +46,16 @@ class StubLtiHandler(StubHttpRequestHandler):
status_message = 'LTI consumer (edX) responded with XML content:<br>' + self.server.grade_data['TC answer']
content = self._create_content(status_message)
self.send_response(200, content)
elif 'lti2_outcome' in self.path and self._send_lti2_outcome().status_code == 200:
status_message = 'LTI consumer (edX) responded with HTTP {}<br>'.format(
self.server.grade_data['status_code'])
content = self._create_content(status_message)
self.send_response(200, content)
elif 'lti2_delete' in self.path and self._send_lti2_delete().status_code == 200:
status_message = 'LTI consumer (edX) responded with HTTP {}<br>'.format(
self.server.grade_data['status_code'])
content = self._create_content(status_message)
self.send_response(200, content)
# Respond to request with correct lti endpoint
elif self._is_correct_lti_request():
params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'}
@@ -57,7 +66,7 @@ class StubLtiHandler(StubHttpRequestHandler):
# Set data for grades what need to be stored as server data
if 'lis_outcome_service_url' in self.post_dict:
self.server.grade_data = {
'callback_url': self.post_dict.get('lis_outcome_service_url'),
'callback_url': self.post_dict.get('lis_outcome_service_url').replace('https', 'http'),
'sourcedId': self.post_dict.get('lis_result_sourcedid')
}
@@ -122,16 +131,75 @@ class StubLtiHandler(StubHttpRequestHandler):
self.server.grade_data['TC answer'] = response.content
return response
def _send_lti2_outcome(self):
"""
Send a grade back to consumer
"""
payload = textwrap.dedent("""
{{
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type" : "Result",
"resultScore" : {score},
"comment" : "This is awesome."
}}
""")
data = payload.format(score=0.8)
return self._send_lti2(data)
def _send_lti2_delete(self):
"""
Send a delete back to consumer
"""
payload = textwrap.dedent("""
{
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type" : "Result"
}
""")
return self._send_lti2(payload)
def _send_lti2(self, payload):
"""
Send lti2 json result service request.
"""
### We compute the LTI V2.0 service endpoint from the callback_url (which is set by the launch call)
url = self.server.grade_data['callback_url']
url_parts = url.split('/')
url_parts[-1] = "lti_2_0_result_rest_handler"
anon_id = self.server.grade_data['sourcedId'].split(":")[-1]
url_parts.extend(["user", anon_id])
new_url = '/'.join(url_parts)
content_type = 'application/vnd.ims.lis.v2.result+json'
headers = {
'Content-Type': content_type,
'Authorization': self._oauth_sign(new_url, payload,
method='PUT',
content_type=content_type)
}
# Send request ignoring verifirecation of SSL certificate
response = requests.put(new_url, data=payload, headers=headers, verify=False)
self.server.grade_data['status_code'] = response.status_code
self.server.grade_data['TC answer'] = response.content
return response
def _create_content(self, response_text, submit_url=None):
"""
Return content (str) either for launch, send grade or get result from TC.
"""
if submit_url:
submit_form = textwrap.dedent("""
<form action="{}/grade" method="post">
<form action="{submit_url}/grade" method="post">
<input type="submit" name="submit-button" value="Submit">
</form>
""").format(submit_url)
<form action="{submit_url}/lti2_outcome" method="post">
<input type="submit" name="submit-lti2-button" value="Submit">
</form>
<form action="{submit_url}/lti2_delete" method="post">
<input type="submit" name="submit-lti2-delete-button" value="Submit">
</form>
""").format(submit_url=submit_url)
else:
submit_form = ''
@@ -169,9 +237,9 @@ class StubLtiHandler(StubHttpRequestHandler):
lti_endpoint = self.server.config.get('lti_endpoint', self.DEFAULT_LTI_ENDPOINT)
return lti_endpoint in self.path
def _oauth_sign(self, url, body):
def _oauth_sign(self, url, body, content_type=u'application/x-www-form-urlencoded', method=u'POST'):
"""
Signs request and returns signed body and headers.
Signs request and returns signed Authorization header.
"""
client_key = self.server.config.get('client_key', self.DEFAULT_CLIENT_KEY)
client_secret = self.server.config.get('client_secret', self.DEFAULT_CLIENT_SECRET)
@@ -181,21 +249,27 @@ class StubLtiHandler(StubHttpRequestHandler):
)
headers = {
# This is needed for body encoding:
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': content_type,
}
# Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html
sha1 = hashlib.sha1()
sha1.update(body)
oauth_body_hash = base64.b64encode(sha1.digest())
__, headers, __ = client.sign(
unicode(url.strip()),
http_method=u'POST',
body={u'oauth_body_hash': oauth_body_hash},
headers=headers
oauth_body_hash = unicode(base64.b64encode(sha1.digest())) # pylint: disable=too-many-function-args
params = client.get_oauth_params()
params.append((u'oauth_body_hash', oauth_body_hash))
mock_request = mock.Mock(
uri=unicode(urllib.unquote(url)),
headers=headers,
body=u"",
decoded_body=u"",
oauth_params=params,
http_method=unicode(method),
)
headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash)
return headers
sig = client.get_oauth_signature(mock_request)
mock_request.oauth_params.append((u'oauth_signature', sig))
new_headers = parameters.prepare_headers(mock_request.oauth_params, headers, realm=None)
return new_headers['Authorization']
def _check_oauth_signature(self, params, client_signature):
"""

View File

@@ -62,7 +62,7 @@ class StubLtiServiceTest(unittest.TestCase):
self.assertIn('This is LTI tool. Success.', response.content)
@patch('terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True)
def test_send_graded_result(self, verify_hmac):
def test_send_graded_result(self, verify_hmac): # pylint: disable=unused-argument
response = requests.post(self.launch_uri, data=self.payload)
self.assertIn('This is LTI tool. Success.', response.content)
grade_uri = self.uri + 'grade'
@@ -70,3 +70,23 @@ class StubLtiServiceTest(unittest.TestCase):
mocked_post.return_value = Mock(content='Test response', status_code=200)
response = urllib2.urlopen(grade_uri, data='')
self.assertIn('Test response', response.read())
@patch('terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True)
def test_lti20_outcomes_put(self, verify_hmac): # pylint: disable=unused-argument
response = requests.post(self.launch_uri, data=self.payload)
self.assertIn('This is LTI tool. Success.', response.content)
grade_uri = self.uri + 'lti2_outcome'
with patch('terrain.stubs.lti.requests.put') as mocked_put:
mocked_put.return_value = Mock(status_code=200)
response = urllib2.urlopen(grade_uri, data='')
self.assertIn('LTI consumer (edX) responded with HTTP 200', response.read())
@patch('terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True)
def test_lti20_outcomes_put_like_delete(self, verify_hmac): # pylint: disable=unused-argument
response = requests.post(self.launch_uri, data=self.payload)
self.assertIn('This is LTI tool. Success.', response.content)
grade_uri = self.uri + 'lti2_delete'
with patch('terrain.stubs.lti.requests.put') as mocked_put:
mocked_put.return_value = Mock(status_code=200)
response = urllib2.urlopen(grade_uri, data='')
self.assertIn('LTI consumer (edX) responded with HTTP 200', response.read())

View File

@@ -350,7 +350,7 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
user_inactive = user and not user.is_active
user_unset = user is None
dispatch_to_login = (is_login and user_unset) or user_inactive
dispatch_to_login = is_login and (user_unset or user_inactive)
if is_dashboard:
return

View File

@@ -640,21 +640,17 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
created_user = self.get_user_by_email(strategy, email)
self.assert_password_overridden_by_pipeline(overridden_password, created_user.username)
# The user's account isn't created yet, so an attempt to complete the
# pipeline will error out on /login:
self.assert_redirect_to_login_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=created_user))
# So we activate the account in order to verify the redirect to /dashboard:
created_user.is_active = True
created_user.save()
# At this point the user object exists, but there is no associated
# social auth.
self.assert_social_auth_does_not_exist_for_user(created_user, strategy)
# Last step in the pipeline: we re-invoke the pipeline and expect to
# end up on /dashboard, with the correct social auth object now in the
# backend and the correct user's data on display.
# Pick the pipeline back up. This will create the account association
# and send the user to the dashboard, where the association will be
# displayed.
self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=created_user))
self.assert_social_auth_exists_for_user(created_user, strategy)
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), created_user)
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), created_user, linked=True)
def test_new_account_registration_assigns_distinct_username_on_collision(self):
original_username = self.get_username()

View File

@@ -12,6 +12,12 @@ from eventtracking import tracker
log = logging.getLogger(__name__)
CONTEXT_NAME = 'edx.request'
META_KEY_TO_CONTEXT_KEY = {
'REMOTE_ADDR': 'ip',
'SERVER_NAME': 'host',
'HTTP_USER_AGENT': 'agent',
'PATH_INFO': 'path'
}
class TrackMiddleware(object):
@@ -78,26 +84,58 @@ class TrackMiddleware(object):
"""
Extract information from the request and add it to the tracking
context.
The following fields are injected in to the context:
* session - The Django session key that identifies the user's session.
* user_id - The numeric ID for the logged in user.
* username - The username of the logged in user.
* ip - The IP address of the client.
* host - The "SERVER_NAME" header, which should be the name of the server running this code.
* agent - The client browser identification string.
* path - The path part of the requested URL.
"""
context = {}
context = {
'session': self.get_session_key(request),
'user_id': self.get_user_primary_key(request),
'username': self.get_username(request),
}
for header_name, context_key in META_KEY_TO_CONTEXT_KEY.iteritems():
context[context_key] = request.META.get(header_name, '')
context.update(contexts.course_context_from_url(request.build_absolute_uri()))
try:
context['user_id'] = request.user.pk
except AttributeError:
context['user_id'] = ''
if settings.DEBUG:
log.error('Cannot determine primary key of logged in user.')
tracker.get_tracker().enter_context(
CONTEXT_NAME,
context
)
def process_response(self, request, response): # pylint: disable=unused-argument
def get_session_key(self, request):
"""Gets the Django session key from the request or an empty string if it isn't found"""
try:
return request.session.session_key
except AttributeError:
return ''
def get_user_primary_key(self, request):
"""Gets the primary key of the logged in Django user"""
try:
return request.user.pk
except AttributeError:
return ''
def get_username(self, request):
"""Gets the username of the logged in Django user"""
try:
return request.user.username
except AttributeError:
return ''
def process_response(self, _request, response):
"""Exit the context if it exists."""
try:
tracker.get_tracker().exit_context(CONTEXT_NAME)
except: # pylint: disable=bare-except
except Exception: # pylint: disable=broad-except
pass
return response

View File

@@ -0,0 +1,42 @@
"""Map new event context values to old top-level field values. Ensures events can be parsed by legacy parsers."""
CONTEXT_FIELDS_TO_INCLUDE = [
'username',
'session',
'ip',
'agent',
'host'
]
class LegacyFieldMappingProcessor(object):
"""Ensures all required fields are included in emitted events"""
def __call__(self, event):
if 'context' in event:
context = event['context']
for field in CONTEXT_FIELDS_TO_INCLUDE:
if field in context:
event[field] = context[field]
del context[field]
else:
event[field] = ''
if 'event_type' in event.get('context', {}):
event['event_type'] = event['context']['event_type']
del event['context']['event_type']
else:
event['event_type'] = event.get('name', '')
if 'data' in event:
event['event'] = event['data']
del event['data']
else:
event['event'] = {}
if 'timestamp' in event:
event['time'] = event['timestamp']
del event['timestamp']
event['event_source'] = 'server'
event['page'] = None

View File

@@ -1,8 +1,10 @@
import re
from mock import patch
from mock import sentinel
from django.contrib.auth.models import User
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
@@ -50,35 +52,86 @@ class TrackMiddlewareTestCase(TestCase):
self.track_middleware.process_request(request)
self.assertFalse(self.mock_server_track.called)
def test_request_in_course_context(self):
request = self.request_factory.get('/courses/test_org/test_course/test_run/foo')
self.track_middleware.process_request(request)
captured_context = tracker.get_tracker().resolve_context()
self.track_middleware.process_response(request, None)
def test_default_request_context(self):
context = self.get_context_for_path('/courses/')
self.assertEquals(context, {
'user_id': '',
'session': '',
'username': '',
'ip': '127.0.0.1',
'host': 'testserver',
'agent': '',
'path': '/courses/',
'org_id': '',
'course_id': '',
})
def get_context_for_path(self, path):
"""Extract the generated event tracking context for a given request for the given path."""
request = self.request_factory.get(path)
return self.get_context_for_request(request)
def get_context_for_request(self, request):
"""Extract the generated event tracking context for the given request."""
self.track_middleware.process_request(request)
try:
captured_context = tracker.get_tracker().resolve_context()
finally:
self.track_middleware.process_response(request, None)
self.assertEquals(
captured_context,
{
'course_id': 'test_org/test_course/test_run',
'org_id': 'test_org',
'user_id': ''
}
)
self.assertEquals(
tracker.get_tracker().resolve_context(),
{}
)
return captured_context
def test_request_in_course_context(self):
captured_context = self.get_context_for_path('/courses/test_org/test_course/test_run/foo')
expected_context_subset = {
'course_id': 'test_org/test_course/test_run',
'org_id': 'test_org',
}
self.assert_dict_subset(captured_context, expected_context_subset)
def assert_dict_subset(self, superset, subset):
"""Assert that the superset dict contains all of the key-value pairs found in the subset dict."""
for key, expected_value in subset.iteritems():
self.assertEquals(superset[key], expected_value)
def test_request_with_user(self):
user_id = 1
username = sentinel.username
request = self.request_factory.get('/courses/')
request.user = User(pk=1)
self.track_middleware.process_request(request)
self.addCleanup(self.track_middleware.process_response, request, None)
self.assertEquals(
tracker.get_tracker().resolve_context(),
{
'course_id': '',
'org_id': '',
'user_id': 1
}
)
request.user = User(pk=user_id, username=username)
context = self.get_context_for_request(request)
self.assert_dict_subset(context, {
'user_id': user_id,
'username': username,
})
def test_request_with_session(self):
request = self.request_factory.get('/courses/')
SessionMiddleware().process_request(request)
request.session.save()
session_key = request.session.session_key
context = self.get_context_for_request(request)
self.assert_dict_subset(context, {
'session': session_key,
})
def test_request_headers(self):
ip_address = '10.0.0.0'
user_agent = 'UnitTest/1.0'
factory = RequestFactory(REMOTE_ADDR=ip_address, HTTP_USER_AGENT=user_agent)
request = factory.get('/some-path')
context = self.get_context_for_request(request)
self.assert_dict_subset(context, {
'ip': ip_address,
'agent': user_agent,
})

View File

@@ -0,0 +1,121 @@
"""Ensure emitted events contain the fields legacy processors expect to find."""
from datetime import datetime
from freezegun import freeze_time
from mock import sentinel
from django.test import TestCase
from django.test.utils import override_settings
from pytz import UTC
from eventtracking.django import DjangoTracker
IN_MEMORY_BACKEND = {
'mem': {
'ENGINE': 'track.tests.test_shim.InMemoryBackend'
}
}
LEGACY_SHIM_PROCESSOR = [
{
'ENGINE': 'track.shim.LegacyFieldMappingProcessor'
}
]
FROZEN_TIME = datetime(2013, 10, 3, 8, 24, 55, tzinfo=UTC)
@freeze_time(FROZEN_TIME)
class LegacyFieldMappingProcessorTestCase(TestCase):
"""Ensure emitted events contain the fields legacy processors expect to find."""
@override_settings(
EVENT_TRACKING_BACKENDS=IN_MEMORY_BACKEND,
EVENT_TRACKING_PROCESSORS=LEGACY_SHIM_PROCESSOR,
)
def test_event_field_mapping(self):
django_tracker = DjangoTracker()
data = {sentinel.key: sentinel.value}
context = {
'username': sentinel.username,
'session': sentinel.session,
'ip': sentinel.ip,
'host': sentinel.host,
'agent': sentinel.agent,
'path': sentinel.path,
'user_id': sentinel.user_id,
'course_id': sentinel.course_id,
'org_id': sentinel.org_id,
'event_type': sentinel.event_type,
}
with django_tracker.context('test', context):
django_tracker.emit(sentinel.name, data)
emitted_event = django_tracker.backends['mem'].get_event()
expected_event = {
'event_type': sentinel.event_type,
'name': sentinel.name,
'context': {
'user_id': sentinel.user_id,
'course_id': sentinel.course_id,
'org_id': sentinel.org_id,
'path': sentinel.path,
},
'event': data,
'username': sentinel.username,
'event_source': 'server',
'time': FROZEN_TIME,
'agent': sentinel.agent,
'host': sentinel.host,
'ip': sentinel.ip,
'page': None,
'session': sentinel.session,
}
self.assertEqual(expected_event, emitted_event)
@override_settings(
EVENT_TRACKING_BACKENDS=IN_MEMORY_BACKEND,
EVENT_TRACKING_PROCESSORS=LEGACY_SHIM_PROCESSOR,
)
def test_missing_fields(self):
django_tracker = DjangoTracker()
django_tracker.emit(sentinel.name)
emitted_event = django_tracker.backends['mem'].get_event()
expected_event = {
'event_type': sentinel.name,
'name': sentinel.name,
'context': {},
'event': {},
'username': '',
'event_source': 'server',
'time': FROZEN_TIME,
'agent': '',
'host': '',
'ip': '',
'page': None,
'session': '',
}
self.assertEqual(expected_event, emitted_event)
class InMemoryBackend(object):
"""A backend that simply stores all events in memory"""
def __init__(self):
super(InMemoryBackend, self).__init__()
self.events = []
def send(self, event):
"""Store the event in a list"""
self.events.append(event)
def get_event(self):
"""Return the first event that was emitted."""
return self.events[0]

View File

@@ -168,7 +168,7 @@ def add_staff_markup(user, block, view, frag, context): # pylint: disable=unuse
Does nothing if module is a SequenceModule.
"""
# TODO: make this more general, eg use an XModule attribute instead
if isinstance(block, VerticalModule):
if isinstance(block, VerticalModule) and (not context or not context.get('child_of_vertical', False)):
# check that the course is a mongo backed Studio course before doing work
is_mongo_course = modulestore().get_modulestore_type(block.location.course_key) == MONGO_MODULESTORE_TYPE
is_studio_course = block.course_edit_method == "Studio"

View File

@@ -1375,6 +1375,7 @@ class StringResponse(LoncapaResponse):
Note: for old code, which supports _or_ separator, we add some backward compatibility handling.
Should be removed soon. When to remove it, is up to Lyla Fisher.
"""
_ = self.capa_system.i18n.ugettext
# backward compatibility, should be removed in future.
if self.backward:
return self.check_string_backward(expected, given)
@@ -1386,7 +1387,10 @@ class StringResponse(LoncapaResponse):
regexp = re.compile('^' + '|'.join(expected) + '$', flags=flags | re.UNICODE)
result = re.search(regexp, given)
except Exception as err:
msg = '[courseware.capa.responsetypes.stringresponse] error: {}'.format(err.message)
msg = u'[courseware.capa.responsetypes.stringresponse] {error}: {message}'.format(
error=_(u'error'),
message=err.message
)
log.error(msg, exc_info=True)
raise ResponseError(msg)
return bool(result)
@@ -1410,7 +1414,9 @@ class StringResponse(LoncapaResponse):
return hints_to_show
def get_answers(self):
return {self.answer_id: ' <b>or</b> '.join(self.correct_answer)}
_ = self.capa_system.i18n.ugettext
separator = u' <b>{}</b> '.format(_(u'or'))
return {self.answer_id: separator.join(self.correct_answer)}
#-----------------------------------------------------------------------------
@@ -1505,6 +1511,7 @@ class CustomResponse(LoncapaResponse):
student_answers is a dict with everything from request.POST, but with the first part
of each key removed (the string before the first "_").
"""
_ = self.capa_system.i18n.ugettext
log.debug('%s: student_answers=%s', unicode(self), student_answers)
@@ -1514,9 +1521,16 @@ class CustomResponse(LoncapaResponse):
# ordered list of answers
submission = [student_answers[k] for k in idset]
except Exception as err:
msg = ('[courseware.capa.responsetypes.customresponse] error getting'
' student answer from %s' % student_answers)
msg += '\n idset = %s, error = %s' % (idset, err)
msg = _(
"[courseware.capa.responsetypes.customresponse] error getting"
" student answer from {student_answers}"
"\n idset = {idset}, error = {err}"
).format(
student_answers=student_answers,
idset=idset,
err=err
);
log.error(msg)
raise Exception(msg)
@@ -1529,7 +1543,7 @@ class CustomResponse(LoncapaResponse):
# default to no error message on empty answer (to be consistent with other
# responsetypes) but allow author to still have the old behavior by setting
# empty_answer_err attribute
msg = ('<span class="inline-error">No answer entered!</span>'
msg = (u'<span class="inline-error">{0}</span>'.format(_(u'No answer entered!'))
if self.xml.get('empty_answer_err') else '')
return CorrectMap(idset[0], 'incorrect', msg=msg)
@@ -1778,9 +1792,14 @@ class SymbolicResponse(CustomResponse):
debug=self.context.get('debug'),
)
except Exception as err:
log.error("oops in symbolicresponse (cfn) error %s", err)
log.error("oops in SymbolicResponse (cfn) error %s", err)
log.error(traceback.format_exc())
raise Exception("oops in symbolicresponse (cfn) error %s", err)
_ = self.capa_system.i18n.ugettext
# Translators: 'SymbolicResponse' is a problem type and should not be translated.
msg = _(u"oops in SymbolicResponse (cfn) error {error_msg}").format(
error_msg=err,
)
raise Exception(msg)
self.context['messages'][0] = self.clean_message_html(ret['msg'])
self.context['correct'] = ['correct' if ret['ok'] else 'incorrect'] * len(idset)
@@ -1863,10 +1882,12 @@ class CodeResponse(LoncapaResponse):
self.initial_display = find_with_default(
codeparam, 'initial_display', '')
_ = self.capa_system.i18n.ugettext
self.answer = find_with_default(codeparam, 'answer_display',
'No answer provided.')
_(u'No answer provided.'))
def get_score(self, student_answers):
_ = self.capa_system.i18n.ugettext
try:
# Note that submission can be a file
submission = student_answers[self.answer_id]
@@ -1882,7 +1903,7 @@ class CodeResponse(LoncapaResponse):
if self.capa_system.xqueue is None:
cmap = CorrectMap()
cmap.set(self.answer_id, queuestate=None,
msg='Error checking problem: no external queueing server is configured.')
msg=_(u'Error checking problem: no external queueing server is configured.'))
return cmap
# Prepare xqueue request

View File

@@ -369,6 +369,9 @@ class CourseFields(object):
)
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
scope=Scope.settings)
certificates_show_before_end = Boolean(help="True if students may download certificates before course end",
scope=Scope.settings,
default=False)
course_image = String(
help="Filename of the course image",
scope=Scope.settings,
@@ -592,6 +595,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
return datetime.now(UTC()) > self.end
def may_certify(self):
"""
Return True if it is acceptable to show the student a certificate download link
"""
return self.certificates_show_before_end or self.has_ended()
def has_started(self):
return datetime.now(UTC()) > self.start

View File

@@ -266,8 +266,8 @@ th {
.image-content .image-wrapper {
top: 0 !important;
left: 0 !important;
width: auto !important;
height: auto !important;
width: 100% !important;
height: 100% !important;
img {
top: 0 !important;

View File

@@ -1,3 +1,16 @@
h2.problem-header {
display: inline-block;
}
div.problem-progress {
display: inline-block;
padding-left: 5px;
color: #666;
font-weight: 100;
font-size: em(16);
}
div.lti {
// align center
margin: 0 auto;
@@ -31,4 +44,16 @@ div.lti {
display: block;
border: 0px;
}
h4.problem-feedback-label {
font-weight: 100;
font-size: em(16);
font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif;
}
div.problem-feedback {
margin-top: 5px;
margin-bottom: 5px;
}
}

View File

@@ -20,6 +20,18 @@ describe 'OpenEndedMarkdownEditingDescriptor', ->
expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('xml only')
describe 'advanced editor opens correctly', ->
it 'click on advanced editor should work', ->
loadFixtures 'combinedopenended-with-markdown.html'
@descriptor = new OpenEndedMarkdownEditingDescriptor($('.combinedopenended-editor'))
spyOn(@descriptor, 'confirmConversionToXml').andReturn(true)
expect(@descriptor.confirmConversionToXml).not.toHaveBeenCalled()
e = jasmine.createSpyObj('e', [ 'preventDefault' ])
@descriptor.onShowXMLButton(e)
expect(e.preventDefault).toHaveBeenCalled()
expect(@descriptor.confirmConversionToXml).toHaveBeenCalled()
expect($('.editor-bar').length).toEqual(0)
describe 'insertPrompt', ->
it 'inserts the template if selection is empty', ->
revisedSelection = OpenEndedMarkdownEditingDescriptor.insertPrompt('')

View File

@@ -20,6 +20,18 @@ describe 'MarkdownEditingDescriptor', ->
expect(saveResult.nullout).toEqual(['markdown'])
expect(saveResult.data).toEqual('xml only')
describe 'advanced editor opens correctly', ->
it 'click on advanced editor should work', ->
loadFixtures 'problem-with-markdown.html'
@descriptor = new MarkdownEditingDescriptor($('.problem-editor'))
spyOn(@descriptor, 'confirmConversionToXml').andReturn(true)
expect(@descriptor.confirmConversionToXml).not.toHaveBeenCalled()
e = jasmine.createSpyObj('e', [ 'preventDefault' ])
@descriptor.onShowXMLButton(e)
expect(e.preventDefault).toHaveBeenCalled()
expect(@descriptor.confirmConversionToXml).toHaveBeenCalled()
expect($('.editor-bar').length).toEqual(0)
describe 'insertMultipleChoice', ->
it 'inserts the template if selection is empty', ->
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('')
@@ -538,7 +550,7 @@ describe 'MarkdownEditingDescriptor', ->
<p>What is the capital of Germany?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choicegroup label="What is the capital of Germany?" type="MultipleChoice">
<choice correct="false">Bonn</choice>
<choice correct="false">Hamburg</choice>
<choice correct="true">Berlin</choice>

View File

@@ -87,6 +87,8 @@ Write a persuasive essay to a newspaper reflecting your views on censorship in l
###
onShowXMLButton: (e) =>
e.preventDefault();
if @cheatsheet != undefined
@addRemoveCheatsheetCSS()
if @confirmConversionToXml()
@createXMLEditor(OpenEndedMarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()))
# Need to refresh to get line numbers to display properly (and put cursor position to 0)
@@ -131,8 +133,23 @@ Write a persuasive essay to a newspaper reflecting your views on censorship in l
@cheatsheet = $($('#simple-editor-open-ended-cheatsheet').html())
$(@markdown_editor.getWrapperElement()).append(@cheatsheet)
@addRemoveCheatsheetCSS()
setTimeout (=> @cheatsheet.toggleClass('shown')), 10
###
Function to add/remove CSS for cheatsheet.
###
addRemoveCheatsheetCSS: () =>
if !@cheatsheet.hasClass("shown")
$(".CodeMirror").css({"overflow": "visible"})
$(".modal-content").css({"overflow-y": "visible", "overflow-x": "visible"})
else
$(".CodeMirror").css({"overflow": ""})
$(".modal-content").removeAttr("style")
###
Stores the current editor and hides the one that is not displayed.
###

View File

@@ -48,7 +48,8 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
###
onShowXMLButton: (e) =>
e.preventDefault();
@addRemoveCheatsheetCSS()
if @cheatsheet != undefined
@addRemoveCheatsheetCSS()
if @confirmConversionToXml()
@createXMLEditor(MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()))
# Need to refresh to get line numbers to display properly (and put cursor position to 0)
@@ -359,7 +360,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
// looks for >>arbitrary text<< and inserts it into the label attribute of the input type directly below the text.
var split = xml.split('\n');
var new_xml = [];
var line, i, curlabel = '';
var line, i, curlabel, prevlabel = '';
var didinput = false;
for (i = 0; i < split.length; i++) {
line = split[i];
@@ -370,13 +371,14 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
line = line.replace(/>>|<</g, '');
} else if (line.match(/<\w+response/) && didinput) {
} else if (line.match(/<\w+response/) && didinput && curlabel == prevlabel) {
// reset label to prevent gobbling up previous one (if multiple questions)
curlabel = '';
didinput = false;
} else if (line.match(/<(textline|optioninput|formulaequationinput|choicegroup|checkboxgroup)/) && curlabel != '') {
} else if (line.match(/<(textline|optioninput|formulaequationinput|choicegroup|checkboxgroup)/) && curlabel != '' && curlabel != undefined) {
line = line.replace(/<(textline|optioninput|formulaequationinput|choicegroup|checkboxgroup)/, '<$1 label="' + curlabel + '"');
didinput = true;
prevlabel = curlabel;
}
new_xml.push(line);
}

View File

@@ -0,0 +1,363 @@
# pylint: disable=attribute-defined-outside-init
"""
A mixin class for LTI 2.0 functionality. This is really just done to refactor the code to
keep the LTIModule class from getting too big
"""
import json
import re
import mock
import urllib
import hashlib
import base64
import logging
from webob import Response
from xblock.core import XBlock
from oauthlib.oauth1 import Client
log = logging.getLogger(__name__)
LTI_2_0_REST_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>\w+)", re.UNICODE)
LTI_2_0_JSON_CONTENT_TYPE = 'application/vnd.ims.lis.v2.result+json'
class LTIError(Exception):
"""Error class for LTIModule and LTI20ModuleMixin"""
pass
class LTI20ModuleMixin(object):
"""
This class MUST be mixed into LTIModule. It does not do anything on its own. It's just factored
out for modularity.
"""
# LTI 2.0 Result Service Support
@XBlock.handler
def lti_2_0_result_rest_handler(self, request, suffix):
"""
Handler function for LTI 2.0 JSON/REST result service.
See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
An example JSON object:
{
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type" : "Result",
"resultScore" : 0.83,
"comment" : "This is exceptional work."
}
For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json".
We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is
http://localhost:8000/courses/org/num/run/xblock/i4x:;_;_org;_num;_lti;_GUID/handler_noauth/lti_2_0_result_rest_handler/user/<anon_id>
so suffix is of the form "user/<anon_id>"
Failures result in 401, 404, or 500s without any body. Successes result in 200. Again see
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
(Note: this prevents good debug messages for the client, so we might want to change this, or the spec)
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request
suffix (unicode): request path after "lti_2_0_result_rest_handler/". expected to be "user/<anon_id>"
Returns:
webob.response: response to this request. See above for details.
"""
if self.system.debug:
self._log_correct_authorization_header(request)
try:
anon_id = self.parse_lti_2_0_handler_suffix(suffix)
except LTIError:
return Response(status=404) # 404 because a part of the URL (denoting the anon user id) is invalid
try:
self.verify_lti_2_0_result_rest_headers(request, verify_content_type=True)
except LTIError:
return Response(status=401) # Unauthorized in this case. 401 is right
real_user = self.system.get_real_user(anon_id)
if not real_user: # that means we can't save to database, as we do not have real user id.
msg = "[LTI]: Real user not found against anon_id: {}".format(anon_id)
log.info(msg)
return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body
if request.method == "PUT":
return self._lti_2_0_result_put_handler(request, real_user)
elif request.method == "GET":
return self._lti_2_0_result_get_handler(request, real_user)
elif request.method == "DELETE":
return self._lti_2_0_result_del_handler(request, real_user)
else:
return Response(status=404) # have to do 404 due to spec, but 405 is better, with error msg in body
def _log_correct_authorization_header(self, request):
"""
Helper function that logs proper HTTP Authorization header for a given request
Used only in debug situations, this logs the correct Authorization header based on
the request header and body according to OAuth 1 Body signing
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object to log Authorization header for
Returns:
nothing
"""
sha1 = hashlib.sha1()
sha1.update(request.body)
oauth_body_hash = unicode(base64.b64encode(sha1.digest())) # pylint: disable=too-many-function-args
log.debug("[LTI] oauth_body_hash = {}".format(oauth_body_hash))
client_key, client_secret = self.get_client_key_secret()
client = Client(client_key, client_secret)
params = client.get_oauth_params()
params.append((u'oauth_body_hash', oauth_body_hash))
mock_request = mock.Mock(
uri=unicode(urllib.unquote(request.url)),
headers=request.headers,
body=u"",
decoded_body=u"",
oauth_params=params,
http_method=unicode(request.method),
)
sig = client.get_oauth_signature(mock_request)
mock_request.oauth_params.append((u'oauth_signature', sig))
_, headers, _ = client._render(mock_request) # pylint: disable=protected-access
log.debug("\n\n#### COPY AND PASTE AUTHORIZATION HEADER ####\n{}\n####################################\n\n"
.format(headers['Authorization']))
def parse_lti_2_0_handler_suffix(self, suffix):
"""
Parser function for HTTP request path suffixes
parses the suffix argument (the trailing parts of the URL) of the LTI2.0 REST handler.
must be of the form "user/<anon_id>". Returns anon_id if match found, otherwise raises LTIError
Arguments:
suffix (unicode): suffix to parse
Returns:
unicode: anon_id if match found
Raises:
LTIError if suffix cannot be parsed or is not in its expected form
"""
if suffix:
match_obj = LTI_2_0_REST_SUFFIX_PARSER.match(suffix)
if match_obj:
return match_obj.group('anon_id')
# fall-through handles all error cases
msg = "No valid user id found in endpoint URL"
log.info("[LTI]: {}".format(msg))
raise LTIError(msg)
def _lti_2_0_result_get_handler(self, request, real_user): # pylint: disable=unused-argument
"""
Helper request handler for GET requests to LTI 2.0 result endpoint
GET handler for lti_2_0_result. Assumes all authorization has been checked.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object (unused)
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
Returns:
webob.response: response to this request, in JSON format with status 200 if success
"""
base_json_obj = {
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type": "Result"
}
self.system.rebind_noauth_module_to_user(self, real_user)
if self.module_score is None: # In this case, no score has been ever set
return Response(json.dumps(base_json_obj), content_type=LTI_2_0_JSON_CONTENT_TYPE)
# Fall through to returning grade and comment
base_json_obj['resultScore'] = round(self.module_score, 2)
base_json_obj['comment'] = self.score_comment
return Response(json.dumps(base_json_obj), content_type=LTI_2_0_JSON_CONTENT_TYPE)
def _lti_2_0_result_del_handler(self, request, real_user): # pylint: disable=unused-argument
"""
Helper request handler for DELETE requests to LTI 2.0 result endpoint
DELETE handler for lti_2_0_result. Assumes all authorization has been checked.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object (unused)
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
Returns:
webob.response: response to this request. status 200 if success
"""
self.clear_user_module_score(real_user)
return Response(status=200)
def _lti_2_0_result_put_handler(self, request, real_user):
"""
Helper request handler for PUT requests to LTI 2.0 result endpoint
PUT handler for lti_2_0_result. Assumes all authorization has been checked.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
Returns:
webob.response: response to this request. status 200 if success. 404 if body of PUT request is malformed
"""
try:
(score, comment) = self.parse_lti_2_0_result_json(request.body)
except LTIError:
return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body
# According to http://www.imsglobal.org/lti/ltiv2p0/ltiIMGv2p0.html#_Toc361225514
# PUTting a JSON object with no "resultScore" field is equivalent to a DELETE.
if score is None:
self.clear_user_module_score(real_user)
return Response(status=200)
# Fall-through record the score and the comment in the module
self.set_user_module_score(real_user, score, self.max_score(), comment)
return Response(status=200)
def clear_user_module_score(self, user):
"""
Clears the module user state, including grades and comments, and also scoring in db's courseware_studentmodule
Arguments:
user (django.contrib.auth.models.User): Actual user whose module state is to be cleared
Returns:
nothing
"""
self.set_user_module_score(user, None, None)
def set_user_module_score(self, user, score, max_score, comment=u""):
"""
Sets the module user state, including grades and comments, and also scoring in db's courseware_studentmodule
Arguments:
user (django.contrib.auth.models.User): Actual user whose module state is to be set
score (float): user's numeric score to set. Must be in the range [0.0, 1.0]
max_score (float): max score that could have been achieved on this module
comment (unicode): comments provided by the grader as feedback to the student
Returns:
nothing
"""
if score is not None and max_score is not None:
scaled_score = score * max_score
else:
scaled_score = None
self.system.rebind_noauth_module_to_user(self, user)
# have to publish for the progress page...
self.system.publish(
self,
'grade',
{
'value': scaled_score,
'max_value': max_score,
'user_id': user.id,
},
)
self.module_score = scaled_score
self.score_comment = comment
def verify_lti_2_0_result_rest_headers(self, request, verify_content_type=True):
"""
Helper method to validate LTI 2.0 REST result service HTTP headers. returns if correct, else raises LTIError
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object
verify_content_type (bool): If true, verifies the content type of the request is that spec'ed by LTI 2.0
Returns:
nothing, but will only return if verification succeeds
Raises:
LTIError if verification fails
"""
content_type = request.headers.get('Content-Type')
if verify_content_type and content_type != LTI_2_0_JSON_CONTENT_TYPE:
log.info("[LTI]: v2.0 result service -- bad Content-Type: {}".format(content_type))
raise LTIError(
"For LTI 2.0 result service, Content-Type must be {}. Got {}".format(LTI_2_0_JSON_CONTENT_TYPE,
content_type))
try:
self.verify_oauth_body_sign(request, content_type=LTI_2_0_JSON_CONTENT_TYPE)
except (ValueError, LTIError) as err:
log.info("[LTI]: v2.0 result service -- OAuth body verification failed: {}".format(err.message))
raise LTIError(err.message)
def parse_lti_2_0_result_json(self, json_str):
"""
Helper method for verifying LTI 2.0 JSON object contained in the body of the request.
The json_str must be loadable. It can either be an dict (object) or an array whose first element is an dict,
in which case that first dict is considered.
The dict must have the "@type" key with value equal to "Result",
"resultScore" key with value equal to a number [0, 1],
The "@context" key must be present, but we don't do anything with it. And the "comment" key may be
present, in which case it must be a string.
Arguments:
json_str (unicode): The body of the LTI 2.0 results service request, which is a JSON string]
Returns:
(float, str): (score, [optional]comment) if verification checks out
Raises:
LTIError (with message) if verification fails
"""
try:
json_obj = json.loads(json_str)
except (ValueError, TypeError):
msg = "Supplied JSON string in request body could not be decoded: {}".format(json_str)
log.info("[LTI] {}".format(msg))
raise LTIError(msg)
# the standard supports a list of objects, who knows why. It must contain at least 1 element, and the
# first element must be a dict
if type(json_obj) != dict:
if type(json_obj) == list and len(json_obj) >= 1 and type(json_obj[0]) == dict:
json_obj = json_obj[0]
else:
msg = ("Supplied JSON string is a list that does not contain an object as the first element. {}"
.format(json_str))
log.info("[LTI] {}".format(msg))
raise LTIError(msg)
# '@type' must be "Result"
result_type = json_obj.get("@type")
if result_type != "Result":
msg = "JSON object does not contain correct @type attribute (should be 'Result', is {})".format(result_type)
log.info("[LTI] {}".format(msg))
raise LTIError(msg)
# '@context' must be present as a key
REQUIRED_KEYS = ["@context"] # pylint: disable=invalid-name
for key in REQUIRED_KEYS:
if key not in json_obj:
msg = "JSON object does not contain required key {}".format(key)
log.info("[LTI] {}".format(msg))
raise LTIError(msg)
# 'resultScore' is not present. If this was a PUT this means it's actually a DELETE according
# to the LTI spec. We will indicate this by returning None as score, "" as comment.
# The actual delete will be handled by the caller
if "resultScore" not in json_obj:
return None, json_obj.get('comment', "")
# if present, 'resultScore' must be a number between 0 and 1 inclusive
try:
score = float(json_obj.get('resultScore', "unconvertable")) # Check if float is present and the right type
if not 0 <= score <= 1:
msg = 'score value outside the permitted range of 0-1.'
log.info("[LTI] {}".format(msg))
raise LTIError(msg)
except (TypeError, ValueError) as err:
msg = "Could not convert resultScore to float: {}".format(err.message)
log.info("[LTI] {}".format(msg))
raise LTIError(msg)
return score, json_obj.get('comment', "")

View File

@@ -22,6 +22,11 @@ A resource to test the LTI protocol (PHP realization):
http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
We have also begun to add support for LTI 1.2/2.0. We will keep this
docstring in synch with what support is available. The first LTI 2.0
feature to be supported is the REST API results service, see specification
at
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
What is supported:
------------------
@@ -30,9 +35,20 @@ What is supported:
2.) Multiple LTI components on a single page.
3.) The use of multiple LTI providers per course.
4.) Use of advanced LTI component that provides back a grade.
a.) The LTI provider sends back a grade to a specified URL.
b.) Currently only action "update" is supported. "Read", and "delete"
actions initially weren't required.
A) LTI 1.1.1 XML endpoint
a.) The LTI provider sends back a grade to a specified URL.
b.) Currently only action "update" is supported. "Read", and "delete"
actions initially weren't required.
B) LTI 2.0 Result Service JSON REST endpoint
(http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html)
a.) Discovery of all such LTI http endpoints for a course. External tools GET from this discovery
endpoint and receive URLs for interacting with individual grading units.
(see lms/djangoapps/courseware/views.py:get_course_lti_endpoints)
b.) GET, PUT and DELETE in LTI Result JSON binding
(http://www.imsglobal.org/lti/ltiv2p0/mediatype/application/vnd/ims/lis/v2/result+json/index.html)
for a provider to synchronize grades into edx-platform. Reading, Setting, and Deleteing
Numeric grades between 0 and 1 and text + basic HTML feedback comments are supported, via
GET / PUT / DELETE HTTP methods respectively
"""
import logging
@@ -42,6 +58,7 @@ import hashlib
import base64
import urllib
import textwrap
import bleach
from lxml import etree
from webob import Response
import mock
@@ -51,15 +68,18 @@ from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.x_module import XModule, module_attr
from xmodule.course_module import CourseDescriptor
from xmodule.lti_2_util import LTI20ModuleMixin, LTIError
from pkg_resources import resource_string
from xblock.core import String, Scope, List, XBlock
from xblock.fields import Boolean, Float
log = logging.getLogger(__name__)
class LTIError(Exception):
pass
DOCS_ANCHOR_TAG = (
"<a target='_blank'"
"href='http://edx.readthedocs.org/projects/ca/en/latest/exercises_tools/lti_component.html'>"
"the edX LTI documentation</a>"
)
class LTIFields(object):
@@ -82,22 +102,95 @@ class LTIFields(object):
https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
"""
display_name = String(display_name="Display Name", help="Display name for this module", scope=Scope.settings, default="LTI")
lti_id = String(help="Id of the tool", default='', scope=Scope.settings)
launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings)
custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings)
graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings)
display_name = String(
display_name="Display Name",
help=(
"Enter the name that students see for this component. "
"Analytics reports may also use the display name to identify this component."
),
scope=Scope.settings,
default="LTI",
)
lti_id = String(
display_name="LTI ID",
help=(
"Enter the LTI ID for the external LTI provider. "
"This value must be the same LTI ID that you entered in the "
"LTI Passports setting on the Advanced Settings page."
"<br />See " + DOCS_ANCHOR_TAG + " for more details on this setting."
),
default='',
scope=Scope.settings
)
launch_url = String(
display_name="LTI URL",
help=(
"Enter the URL of the external tool that this component launches. "
"This setting is only used when Hide External Tool is set to False."
"<br />See " + DOCS_ANCHOR_TAG + " for more details on this setting."
),
default='http://www.example.com',
scope=Scope.settings)
custom_parameters = List(
display_name="Custom Parameters",
help=(
"Add the key/value pair for any custom parameters, such as the page your e-book should open to or "
"the background color for this component."
"<br />See " + DOCS_ANCHOR_TAG + " for more details on this setting."
),
scope=Scope.settings)
open_in_a_new_page = Boolean(
display_name="Open in New Page",
help=(
"Select True if you want students to click a link that opens the LTI tool in a new window. "
"Select False if you want the LTI content to open in an IFrame in the current page. "
"This setting is only used when Hide External Tool is set to False. "
),
default=True,
scope=Scope.settings
)
has_score = Boolean(
display_name="Scored",
help=(
"Select True if this component will receive a numerical score from the external LTI system."
),
default=False,
scope=Scope.settings
)
weight = Float(
help="Weight for student grades.",
display_name="Weight",
help=(
"Enter the number of points possible for this component. "
"The default value is 1.0. "
"This setting is only used when Scored is set to True."
),
default=1.0,
scope=Scope.settings,
values={"min": 0},
)
has_score = Boolean(help="Does this LTI module have score?", default=False, scope=Scope.settings)
module_score = Float(
help="The score kept in the xblock KVS -- duplicate of the published score in django DB",
default=None,
scope=Scope.user_state
)
score_comment = String(
help="Comment as returned from grader, LTI2.0 spec",
default="",
scope=Scope.user_state
)
hide_launch = Boolean(
display_name="Hide External Tool",
help=(
"Select True if you want to use this component as a placeholder for syncing with an external grading "
"system rather than launch an external tool. "
"This setting hides the Launch button and any IFrames for this component."
),
default=False,
scope=Scope.settings
)
class LTIModule(LTIFields, XModule):
class LTIModule(LTIFields, LTI20ModuleMixin, XModule):
"""
Module provides LTI integration to course.
@@ -247,6 +340,18 @@ class LTIModule(LTIFields, XModule):
"""
Returns a context.
"""
# use bleach defaults. see https://github.com/jsocol/bleach/blob/master/bleach/__init__.py
# ALLOWED_TAGS are
# ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'strong', 'ul']
#
# ALLOWED_ATTRIBUTES are
# 'a': ['href', 'title'],
# 'abbr': ['title'],
# 'acronym': ['title'],
#
# This lets all plaintext through.
sanitized_comment = bleach.clean(self.score_comment)
return {
'input_fields': self.get_input_fields(),
@@ -257,6 +362,11 @@ class LTIModule(LTIFields, XModule):
'open_in_a_new_page': self.open_in_a_new_page,
'display_name': self.display_name,
'form_url': self.runtime.handler_url(self, 'preview_handler').rstrip('/?'),
'hide_launch': self.hide_launch,
'has_score': self.has_score,
'weight': self.weight,
'module_score': self.module_score,
'comment': sanitized_comment,
}
def get_html(self):
@@ -278,7 +388,7 @@ class LTIModule(LTIFields, XModule):
assert user_id is not None
return unicode(urllib.quote(user_id))
def get_outcome_service_url(self):
def get_outcome_service_url(self, service_name="grade_handler"):
"""
Return URL for storing grades.
@@ -286,14 +396,10 @@ class LTIModule(LTIFields, XModule):
While testing locally and on Jenkins, mock_lti_server use http.referer
to obtain scheme, so it is ok to have http(s) anyway.
The scheme logic is handled in lms/lib/xblock/runtime.py
"""
scheme = 'http' if 'sandbox' in self.system.hostname or self.system.debug else 'https'
uri = '{scheme}://{host}{path}'.format(
scheme=scheme,
host=self.system.hostname,
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
)
return uri
return self.runtime.handler_url(self, service_name, thirdparty=True).rstrip('/?')
def get_resource_link_id(self):
"""
@@ -451,9 +557,8 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
def max_score(self):
return self.weight if self.has_score else None
@XBlock.handler
def grade_handler(self, request, dispatch):
def grade_handler(self, request, suffix): # pylint: disable=unused-argument
"""
This is called by courseware.module_render, to handle an AJAX call.
@@ -552,15 +657,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
if action == 'replaceResultRequest':
self.system.publish(
self,
'grade',
{
'value': score * self.max_score(),
'max_value': self.max_score(),
'user_id': real_user.id,
}
)
self.set_user_module_score(real_user, score, self.max_score())
values = {
'imsx_codeMajor': 'success',
@@ -605,7 +702,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
return imsx_messageIdentifier, sourcedId, score, action
def verify_oauth_body_sign(self, request):
def verify_oauth_body_sign(self, request, content_type='application/x-www-form-urlencoded'):
"""
Verify grade request from LTI provider using OAuth body signing.
@@ -623,26 +720,26 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
client_key, client_secret = self.get_client_key_secret()
headers = {
'Authorization':unicode(request.headers.get('Authorization')),
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': unicode(request.headers.get('Authorization')),
'Content-Type': content_type,
}
sha1 = hashlib.sha1()
sha1.update(request.body)
oauth_body_hash = base64.b64encode(sha1.digest())
oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
oauth_headers =dict(oauth_params)
oauth_headers = dict(oauth_params)
oauth_signature = oauth_headers.pop('oauth_signature')
mock_request = mock.Mock(
uri=unicode(urllib.unquote(request.url)),
http_method=unicode(request.method),
params=oauth_headers.items(),
signature=oauth_signature
)
if oauth_body_hash != oauth_headers.get('oauth_body_hash'):
raise LTIError("OAuth body hash verification is failed.")
if not signature.verify_hmac_sha1(mock_request, client_secret):
raise LTIError("OAuth signature verification is failed.")
@@ -672,3 +769,6 @@ class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescri
module_class = LTIModule
grade_handler = module_attr('grade_handler')
preview_handler = module_attr('preview_handler')
lti_2_0_result_rest_handler = module_attr('lti_2_0_result_rest_handler')
clear_user_module_score = module_attr('clear_user_module_score')
get_outcome_service_url = module_attr('get_outcome_service_url')

View File

@@ -147,7 +147,11 @@ class DraftModuleStore(MongoModuleStore):
self.refresh_cached_metadata_inheritance_tree(draft_location.course_key)
<<<<<<< HEAD
return self._load_items(source_location.course_key, [original])[0]
=======
return wrap_draft(self._load_items([original])[0])
>>>>>>> edx/master
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False):
"""

View File

@@ -198,7 +198,7 @@ class SplitTestModule(SplitTestFields, XModule):
conditions for staff.
"""
# When rendering a Studio preview, render all of the block's children
if context and context['runtime_type'] == 'studio':
if context and context.get('runtime_type', None) == 'studio':
return self.studio_preview_view(context)
if self.child is None:

View File

@@ -0,0 +1,26 @@
---
metadata:
display_name: (Grade Me!) Button
data: |
<p>By clicking the button below, you assert that you have completed the course in its entirety.</p>
<input type=button value="Yes, I Agree." id="User_Verify_Button" style="margin-bottom: 20px;" />
<p class="verify-button-success-text" style="font-weight: bold; color: #008200;"></p>
<script type="text/javascript">
var success_message = "Your grading and certification request has been received, <br />if you have passed, your certificate should be available in the next 20 minutes.";
document.getElementById('User_Verify_Button').addEventListener("click",
function(event) {
(function(event) {
var linkcontents = $('a.user-link').contents();
$.ajax({
type: 'POST',
url: '/request_certificate',
data: {'course_id': $$course_id},
success: function(data) {
$('.verify-button-success-text').html(success_message);
}
});
}).call(document.getElementById('User_Verify_Button'), event);
});
</script>

View File

@@ -6,7 +6,7 @@ data: |
<p>Some edX classes use extremely large, extremely detailed graphics. To make it easier to understand we can offer two versions of those graphics, with the zoomed section showing when you click on the main view.</p>
<p>The example below is from <a href="https://www.edx.org/course/mit/7-00x/introduction-biology-secret-life/1014" target="_blank">7.00x: Introduction to Biology</a> and shows a subset of the biochemical reactions that cells carry out. </p>
<p>You can view the chemical structures of the molecules by clicking on them. The magnified view also lists the enzymes involved in each step.</p>
<p class="sr">Press spacebar to open the magifier.</p>
<div class="zooming-image-place" style="position: relative;">
<a class="loupe" href="https://studio.edx.org/c4x/edX/DemoX/asset/pathways_detail_01.png">
<img alt="magnify" src="https://studio.edx.org/c4x/edX/DemoX/asset/pathways_overview_01.png" />
@@ -20,6 +20,12 @@ data: |
height: 350,
lightbox: false
});
$(document).keydown(function(event) {
if (event.keyCode == 32) {
event.preventDefault();
$('.loupe img').click();
}
});
});
// ]]></script>
<div id="ap_listener_added"></div>

View File

@@ -6,7 +6,7 @@ data: |
<p>Some edX classes use extremely large, extremely detailed graphics. To make it easier to understand we can offer two versions of those graphics, with the zoomed section showing when you click on the main view.</p>
<p>The example below is from <a href="https://www.edx.org/course/mit/7-00x/introduction-biology-secret-life/1014" target="_blank">7.00x: Introduction to Biology</a> and shows a subset of the biochemical reactions that cells carry out. </p>
<p>You can view the chemical structures of the molecules by clicking on them. The magnified view also lists the enzymes involved in each step.</p>
<p class="sr">Press spacebar to open the magifier.</p>
<div class="zooming-image-place" style="position: relative;">
<a class="loupe" href="https://studio.edx.org/c4x/edX/DemoX/asset/pathways_detail_01.png">
<img alt="magnify" src="https://studio.edx.org/c4x/edX/DemoX/asset/pathways_overview_01.png" />
@@ -20,7 +20,12 @@ data: |
height: 350,
lightbox: false
});
$(document).keydown(function(event) {
if (event.keyCode == 32) {
event.preventDefault();
$('.loupe img').click();
}
});
});
// ]]></script>
<div id="ap_listener_added"></div>

View File

@@ -1,5 +1,5 @@
import unittest
from datetime import datetime
from datetime import datetime, timedelta
from fs.memoryfs import MemoryFS
@@ -49,7 +49,7 @@ class DummySystem(ImportSystem):
)
def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None, end=None):
def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None, end=None, certs=False):
"""Get a dummy course"""
system = DummySystem(load_error_modules=True)
@@ -69,17 +69,61 @@ def get_dummy_course(start, announcement=None, is_new=None, advertised_start=Non
{announcement}
{is_new}
{advertised_start}
{end}>
{end}
certificates_show_before_end="{certs}">
<chapter url="hi" url_name="ch" display_name="CH">
<html url_name="h" display_name="H">Two houses, ...</html>
</chapter>
</course>
'''.format(org=ORG, course=COURSE, start=start, is_new=is_new,
announcement=announcement, advertised_start=advertised_start, end=end)
announcement=announcement, advertised_start=advertised_start, end=end,
certs=certs)
return system.process_xml(start_xml)
class HasEndedMayCertifyTestCase(unittest.TestCase):
"""Double check the semantics around when to finalize courses."""
def setUp(self):
system = DummySystem(load_error_modules=True)
#sample_xml = """
# <course org="{org}" course="{course}" display_organization="{org}_display" display_coursenumber="{course}_display"
# graceperiod="1 day" url_name="test"
# start="2012-01-01T12:00"
# {end}
# certificates_show_before_end={cert}>
# <chapter url="hi" url_name="ch" display_name="CH">
# <html url_name="h" display_name="H">Two houses, ...</html>
# </chapter>
# </course>
#""".format(org=ORG, course=COURSE)
past_end = (datetime.now() - timedelta(days=12)).strftime("%Y-%m-%dT%H:%M:00")
future_end = (datetime.now() + timedelta(days=12)).strftime("%Y-%m-%dT%H:%M:00")
self.past_show_certs = get_dummy_course("2012-01-01T12:00", end=past_end, certs=True)
self.past_noshow_certs = get_dummy_course("2012-01-01T12:00", end=past_end, certs=False)
self.future_show_certs = get_dummy_course("2012-01-01T12:00", end=future_end, certs=True)
self.future_noshow_certs = get_dummy_course("2012-01-01T12:00", end=future_end, certs=False)
#self.past_show_certs = system.process_xml(sample_xml.format(end=past_end, cert=True))
#self.past_noshow_certs = system.process_xml(sample_xml.format(end=past_end, cert=False))
#self.future_show_certs = system.process_xml(sample_xml.format(end=future_end, cert=True))
#self.future_noshow_certs = system.process_xml(sample_xml.format(end=future_end, cert=False))
def test_has_ended(self):
"""Check that has_ended correctly tells us when a course is over."""
self.assertTrue(self.past_show_certs.has_ended())
self.assertTrue(self.past_noshow_certs.has_ended())
self.assertFalse(self.future_show_certs.has_ended())
self.assertFalse(self.future_noshow_certs.has_ended())
def test_may_certify(self):
"""Check that may_certify correctly tells us when a course may wrap."""
self.assertTrue(self.past_show_certs.may_certify())
self.assertTrue(self.past_noshow_certs.may_certify())
self.assertTrue(self.future_show_certs.may_certify())
self.assertFalse(self.future_noshow_certs.may_certify())
class IsNewCourseTestCase(unittest.TestCase):
"""Make sure the property is_new works on courses"""

View File

@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
"""Tests for LTI Xmodule LTIv2.0 functional logic."""
import textwrap
from mock import Mock
from xmodule.lti_module import LTIDescriptor
from xmodule.lti_2_util import LTIError
from . import LogicTest
class LTI20RESTResultServiceTest(LogicTest):
"""Logic tests for LTI module. LTI2.0 REST ResultService"""
descriptor_class = LTIDescriptor
def setUp(self):
super(LTI20RESTResultServiceTest, self).setUp()
self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'}
self.system.get_real_user = Mock()
self.system.publish = Mock()
self.system.rebind_noauth_module_to_user = Mock()
self.user_id = self.xmodule.runtime.anonymous_student_id
self.lti_id = self.xmodule.lti_id
def test_sanitize_get_context(self):
"""Tests that the get_context function does basic sanitization"""
# get_context, unfortunately, requires a lot of mocking machinery
mocked_course = Mock(lti_passports=['lti_id:test_client:test_secret'])
modulestore = Mock()
modulestore.get_item.return_value = mocked_course
runtime = Mock(modulestore=modulestore)
self.xmodule.descriptor.runtime = runtime
self.xmodule.lti_id = "lti_id"
self.xmodule.scope_ids.usage_id = "mocked"
test_cases = ( # (before sanitize, after sanitize)
(u"plaintext", u"plaintext"),
(u"a <script>alert(3)</script>", u"a &lt;script&gt;alert(3)&lt;/script&gt;"), # encodes scripts
(u"<b>bold 包</b>", u"<b>bold 包</b>"), # unicode, and <b> tags pass through
)
for case in test_cases:
self.xmodule.score_comment = case[0]
self.assertEqual(
case[1],
self.xmodule.get_context()['comment']
)
def test_lti20_rest_bad_contenttype(self):
"""
Input with bad content type
"""
with self.assertRaisesRegexp(LTIError, "Content-Type must be"):
request = Mock(headers={u'Content-Type': u'Non-existent'})
self.xmodule.verify_lti_2_0_result_rest_headers(request)
def test_lti20_rest_failed_oauth_body_verify(self):
"""
Input with bad oauth body hash verification
"""
err_msg = "OAuth body verification failed"
self.xmodule.verify_oauth_body_sign = Mock(side_effect=LTIError(err_msg))
with self.assertRaisesRegexp(LTIError, err_msg):
request = Mock(headers={u'Content-Type': u'application/vnd.ims.lis.v2.result+json'})
self.xmodule.verify_lti_2_0_result_rest_headers(request)
def test_lti20_rest_good_headers(self):
"""
Input with good oauth body hash verification
"""
self.xmodule.verify_oauth_body_sign = Mock(return_value=True)
request = Mock(headers={u'Content-Type': u'application/vnd.ims.lis.v2.result+json'})
self.xmodule.verify_lti_2_0_result_rest_headers(request)
# We just want the above call to complete without exceptions, and to have called verify_oauth_body_sign
self.assertTrue(self.xmodule.verify_oauth_body_sign.called)
BAD_DISPATCH_INPUTS = [
None,
u"",
u"abcd"
u"notuser/abcd"
u"user/"
u"user//"
u"user/gbere/"
u"user/gbere/xsdf"
u"user/ಠ益ಠ" # not alphanumeric
]
def test_lti20_rest_bad_dispatch(self):
"""
Test the error cases for the "dispatch" argument to the LTI 2.0 handler. Anything that doesn't
fit the form user/<anon_id>
"""
for einput in self.BAD_DISPATCH_INPUTS:
with self.assertRaisesRegexp(LTIError, "No valid user id found in endpoint URL"):
self.xmodule.parse_lti_2_0_handler_suffix(einput)
GOOD_DISPATCH_INPUTS = [
(u"user/abcd3", u"abcd3"),
(u"user/Äbcdè2", u"Äbcdè2"), # unicode, just to make sure
]
def test_lti20_rest_good_dispatch(self):
"""
Test the good cases for the "dispatch" argument to the LTI 2.0 handler. Anything that does
fit the form user/<anon_id>
"""
for ginput, expected in self.GOOD_DISPATCH_INPUTS:
self.assertEquals(self.xmodule.parse_lti_2_0_handler_suffix(ginput), expected)
BAD_JSON_INPUTS = [
# (bad inputs, error message expected)
([
u"kk", # ValueError
u"{{}", # ValueError
u"{}}", # ValueError
3, # TypeError
{}, # TypeError
], u"Supplied JSON string in request body could not be decoded"),
([
u"3", # valid json, not array or object
u"[]", # valid json, array too small
u"[3, {}]", # valid json, 1st element not an object
], u"Supplied JSON string is a list that does not contain an object as the first element"),
([
u'{"@type": "NOTResult"}', # @type key must have value 'Result'
], u"JSON object does not contain correct @type attribute"),
([
# @context missing
u'{"@type": "Result", "resultScore": 0.1}',
], u"JSON object does not contain required key"),
([
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 100}''' # score out of range
], u"score value outside the permitted range of 0-1."),
([
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": "1b"}''', # score ValueError
u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": {}}''', # score TypeError
], u"Could not convert resultScore to float"),
]
def test_lti20_bad_json(self):
"""
Test that bad json_str to parse_lti_2_0_result_json inputs raise appropriate LTI Error
"""
for error_inputs, error_message in self.BAD_JSON_INPUTS:
for einput in error_inputs:
with self.assertRaisesRegexp(LTIError, error_message):
self.xmodule.parse_lti_2_0_result_json(einput)
GOOD_JSON_INPUTS = [
(u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 0.1}''', u""), # no comment means we expect ""
(u'''
[{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@id": "anon_id:abcdef0123456789",
"resultScore": 0.1}]''', u""), # OK to have array of objects -- just take the first. @id is okay too
(u'''
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"resultScore": 0.1,
"comment": "ಠ益ಠ"}''', u"ಠ益ಠ"), # unicode comment
]
def test_lti20_good_json(self):
"""
Test the parsing of good comments
"""
for json_str, expected_comment in self.GOOD_JSON_INPUTS:
score, comment = self.xmodule.parse_lti_2_0_result_json(json_str)
self.assertEqual(score, 0.1)
self.assertEqual(comment, expected_comment)
GOOD_JSON_PUT = textwrap.dedent(u"""
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@id": "anon_id:abcdef0123456789",
"resultScore": 0.1,
"comment": "ಠ益ಠ"}
""").encode('utf-8')
GOOD_JSON_PUT_LIKE_DELETE = textwrap.dedent(u"""
{"@type": "Result",
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@id": "anon_id:abcdef0123456789",
"comment": "ಠ益ಠ"}
""").encode('utf-8')
def get_signed_lti20_mock_request(self, body, method=u'PUT'):
"""
Example of signed from LTI 2.0 Provider. Signatures and hashes are example only and won't verify
"""
mock_request = Mock()
mock_request.headers = {
'Content-Type': 'application/vnd.ims.lis.v2.result+json',
'Authorization': (
u'OAuth oauth_nonce="135685044251684026041377608307", '
u'oauth_timestamp="1234567890", oauth_version="1.0", '
u'oauth_signature_method="HMAC-SHA1", '
u'oauth_consumer_key="test_client_key", '
u'oauth_signature="my_signature%3D", '
u'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="'
)
}
mock_request.url = u'http://testurl'
mock_request.http_method = method
mock_request.method = method
mock_request.body = body
return mock_request
USER_STANDIN = Mock()
USER_STANDIN.id = 999
def setup_system_xmodule_mocks_for_lti20_request_test(self):
"""
Helper fn to set up mocking for lti 2.0 request test
"""
self.system.get_real_user = Mock(return_value=self.USER_STANDIN)
self.xmodule.max_score = Mock(return_value=1.0)
self.xmodule.get_client_key_secret = Mock(return_value=('test_client_key', u'test_client_secret'))
self.xmodule.verify_oauth_body_sign = Mock()
def test_lti20_put_like_delete_success(self):
"""
The happy path for LTI 2.0 PUT that acts like a delete
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
SCORE = 0.55 # pylint: disable=invalid-name
COMMENT = u"ಠ益ಠ" # pylint: disable=invalid-name
self.xmodule.module_score = SCORE
self.xmodule.score_comment = COMMENT
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT_LIKE_DELETE)
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
# Now assert there's no score
self.assertEqual(response.status_code, 200)
self.assertIsNone(self.xmodule.module_score)
self.assertEqual(self.xmodule.score_comment, u"")
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
self.assertEqual(called_grade_obj, {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None})
self.assertEqual(evt_type, 'grade')
def test_lti20_delete_success(self):
"""
The happy path for LTI 2.0 DELETE
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
SCORE = 0.55 # pylint: disable=invalid-name
COMMENT = u"ಠ益ಠ" # pylint: disable=invalid-name
self.xmodule.module_score = SCORE
self.xmodule.score_comment = COMMENT
mock_request = self.get_signed_lti20_mock_request("", method=u'DELETE')
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
# Now assert there's no score
self.assertEqual(response.status_code, 200)
self.assertIsNone(self.xmodule.module_score)
self.assertEqual(self.xmodule.score_comment, u"")
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
self.assertEqual(called_grade_obj, {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None})
self.assertEqual(evt_type, 'grade')
def test_lti20_put_set_score_success(self):
"""
The happy path for LTI 2.0 PUT that sets a score
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
# Now assert
self.assertEqual(response.status_code, 200)
self.assertEqual(self.xmodule.module_score, 0.1)
self.assertEqual(self.xmodule.score_comment, u"ಠ益ಠ")
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
self.assertEqual(evt_type, 'grade')
self.assertEqual(called_grade_obj, {'user_id': self.USER_STANDIN.id, 'value': 0.1, 'max_value': 1.0})
def test_lti20_get_no_score_success(self):
"""
The happy path for LTI 2.0 GET when there's no score
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
mock_request = self.get_signed_lti20_mock_request("", method=u'GET')
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
# Now assert
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type": "Result"})
def test_lti20_get_with_score_success(self):
"""
The happy path for LTI 2.0 GET when there is a score
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
SCORE = 0.55 # pylint: disable=invalid-name
COMMENT = u"ಠ益ಠ" # pylint: disable=invalid-name
self.xmodule.module_score = SCORE
self.xmodule.score_comment = COMMENT
mock_request = self.get_signed_lti20_mock_request("", method=u'GET')
# Now call the handler
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
# Now assert
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
"@type": "Result",
"resultScore": SCORE,
"comment": COMMENT})
UNSUPPORTED_HTTP_METHODS = ["OPTIONS", "HEAD", "POST", "TRACE", "CONNECT"]
def test_lti20_unsupported_method_error(self):
"""
Test we get a 404 when we don't GET or PUT
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
for bad_method in self.UNSUPPORTED_HTTP_METHODS:
mock_request.method = bad_method
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
self.assertEqual(response.status_code, 404)
def test_lti20_request_handler_bad_headers(self):
"""
Test that we get a 401 when header verification fails
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
self.xmodule.verify_lti_2_0_result_rest_headers = Mock(side_effect=LTIError())
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
self.assertEqual(response.status_code, 401)
def test_lti20_request_handler_bad_dispatch_user(self):
"""
Test that we get a 404 when there's no (or badly formatted) user specified in the url
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, None)
self.assertEqual(response.status_code, 404)
def test_lti20_request_handler_bad_json(self):
"""
Test that we get a 404 when json verification fails
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
self.xmodule.parse_lti_2_0_result_json = Mock(side_effect=LTIError())
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
self.assertEqual(response.status_code, 404)
def test_lti20_request_handler_bad_user(self):
"""
Test that we get a 404 when the supplied user does not exist
"""
self.setup_system_xmodule_mocks_for_lti20_request_test()
self.system.get_real_user = Mock(return_value=None)
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
self.assertEqual(response.status_code, 404)

View File

@@ -2,21 +2,15 @@
"""Test for LTI Xmodule functional logic."""
from mock import Mock, patch, PropertyMock
import mock
import textwrap
import json
from lxml import etree
import json
from webob.request import Request
from copy import copy
from collections import OrderedDict
import urllib
import oauthlib
import hashlib
import base64
from xmodule.lti_module import LTIDescriptor, LTIError
from xmodule.lti_module import LTIDescriptor
from xmodule.lti_2_util import LTIError
from . import LogicTest
@@ -56,6 +50,7 @@ class LTIModuleTest(LogicTest):
""")
self.system.get_real_user = Mock()
self.system.publish = Mock()
self.system.rebind_noauth_module_to_user = Mock()
self.user_id = self.xmodule.runtime.anonymous_student_id
self.lti_id = self.xmodule.lti_id
@@ -239,6 +234,7 @@ class LTIModuleTest(LogicTest):
self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)
self.assertEqual(self.xmodule.module_score, float(self.DEFAULTS['grade']))
def test_user_id(self):
expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id))
@@ -246,13 +242,16 @@ class LTIModuleTest(LogicTest):
self.assertEqual(real_user_id, expected_user_id)
def test_outcome_service_url(self):
expected_outcome_service_url = '{scheme}://{host}{path}'.format(
scheme='http' if self.xmodule.runtime.debug else 'https',
host=self.xmodule.runtime.hostname,
path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?')
)
real_outcome_service_url = self.xmodule.get_outcome_service_url()
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)
mock_url_prefix = 'https://hostname/'
test_service_name = "test_service"
def mock_handler_url(block, handler_name, **kwargs): # pylint: disable=unused-argument
"""Mock function for returning fully-qualified handler urls"""
return mock_url_prefix + handler_name
self.xmodule.runtime.handler_url = Mock(side_effect=mock_handler_url)
real_outcome_service_url = self.xmodule.get_outcome_service_url(service_name=test_service_name)
self.assertEqual(real_outcome_service_url, mock_url_prefix + test_service_name)
def test_resource_link_id(self):
with patch('xmodule.lti_module.LTIModule.location', new_callable=PropertyMock) as mock_location:
@@ -392,13 +391,11 @@ class LTIModuleTest(LogicTest):
def test_max_score(self):
self.xmodule.weight = 100.0
self.xmodule.graded = True
self.assertFalse(self.xmodule.has_score)
self.assertEqual(self.xmodule.max_score(), None)
self.xmodule.has_score = True
self.assertEqual(self.xmodule.max_score(), 100.0)
self.xmodule.graded = False
self.assertEqual(self.xmodule.max_score(), 100.0)
def test_context_id(self):

View File

@@ -3,6 +3,7 @@ from xmodule.x_module import XModule
from xmodule.seq_module import SequenceDescriptor
from xmodule.progress import Progress
from pkg_resources import resource_string
from copy import copy
# HACK: This shouldn't be hard-coded to two types
# OBSOLETE: This obsoletes 'type'
@@ -17,11 +18,30 @@ class VerticalModule(VerticalFields, XModule):
''' Layout module for laying out submodules vertically.'''
def student_view(self, context):
# When rendering a Studio preview, use a different template to support drag and drop.
if context and context.get('runtime_type', None) == 'studio':
return self.studio_preview_view(context)
return self.render_view(context, 'vert_module.html')
def studio_preview_view(self, context):
"""
Renders the Studio preview view, which supports drag and drop.
"""
return self.render_view(context, 'vert_module_studio_view.html')
def render_view(self, context, template_name):
"""
Helper method for rendering student_view and the Studio version.
"""
fragment = Fragment()
contents = []
child_context = {} if not context else copy(context)
child_context['child_of_vertical'] = True
for child in self.get_display_items():
rendered_child = child.render('student_view', context)
rendered_child = child.render('student_view', child_context)
fragment.add_frag_resources(rendered_child)
contents.append({
@@ -29,8 +49,9 @@ class VerticalModule(VerticalFields, XModule):
'content': rendered_child.content
})
fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents
fragment.add_content(self.system.render_template(template_name, {
'items': contents,
'xblock_context': context,
}))
return fragment

View File

@@ -11,6 +11,7 @@ from webob import Response
from xblock.core import XBlock
from xmodule.course_module import CourseDescriptor
from xmodule.exceptions import NotFoundError
from xmodule.fields import RelativeTime
@@ -22,6 +23,7 @@ from .transcripts_utils import (
youtube_speed_dict,
Transcript,
save_to_store,
subs_filename
)
@@ -171,6 +173,39 @@ class VideoStudentViewHandlers(object):
return content, filename, Transcript.mime_types[transcript_format]
def get_static_transcript(self, request):
"""
Courses that are imported with the --nostatic flag do not show
transcripts/captions properly even if those captions are stored inside
their static folder. This adds a last resort method of redirecting to
the static asset path of the course if the transcript can't be found
inside the contentstore and the course has the static_asset_path field
set.
"""
response = Response(status=404)
# Only do redirect for English
if not self.transcript_language == 'en':
return response
video_id = request.GET.get('videoId', None)
if video_id:
transcript_name = video_id
else:
transcript_name = self.sub
if transcript_name:
course_location = CourseDescriptor.id_to_location(self.course_id)
course = self.descriptor.runtime.modulestore.get_item(course_location)
if course.static_asset_path:
response = Response(
status=307,
location='/static/{0}/{1}'.format(
course.static_asset_path,
subs_filename(transcript_name, self.transcript_language)
)
)
return response
@XBlock.handler
def transcript(self, request, dispatch):
"""
@@ -206,13 +241,17 @@ class VideoStudentViewHandlers(object):
if language != self.transcript_language:
self.transcript_language = language
try:
transcript = self.translation(request.GET.get('videoId', None))
except NotFoundError, ex:
log.info(ex.message)
# Try to return static URL redirection as last resort
# if no translation is required
return self.get_static_transcript(request)
except (
TranscriptException,
NotFoundError,
UnicodeDecodeError,
TranscriptException,
TranscriptsGenerationException
) as ex:
log.info(ex.message)

View File

@@ -357,8 +357,8 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
_ = self.runtime.service(self, "i18n").ugettext
video_url.update({
'help': _('A YouTube URL or a link to a file hosted anywhere on the web.'),
'display_name': 'Video URL',
'help': _('The URL for your video. This can be a YouTube URL or a link to an .mp4, .ogg, or .webm video file hosted elsewhere on the Internet.'),
'display_name': 'Default Video URL',
'field_name': 'video_url',
'type': 'VideoList',
'default_value': [get_youtube_link(youtube_id_1_0['default_value'])]

View File

@@ -14,7 +14,7 @@ _ = lambda text: text
class VideoFields(object):
"""Fields for `VideoModule` and `VideoDescriptor`."""
display_name = String(
display_name="Display Name", help="Display name for this module.",
display_name="Component Display Name", help="The name students see. This name appears in the course ribbon and as a header for the video.",
default="Video",
scope=Scope.settings
)
@@ -27,38 +27,38 @@ class VideoFields(object):
# TODO: This should be moved to Scope.content, but this will
# require data migration to support the old video module.
youtube_id_1_0 = String(
help="This is the Youtube ID reference for the normal speed video.",
display_name="Youtube ID",
help="Optional, for older browsers: the YouTube ID for the normal speed video.",
display_name="YouTube ID",
scope=Scope.settings,
default="OEoXaMPEzfM"
)
youtube_id_0_75 = String(
help="Optional, for older browsers: the Youtube ID for the .75x speed video.",
display_name="Youtube ID for .75x speed",
help="Optional, for older browsers: the YouTube ID for the .75x speed video.",
display_name="YouTube ID for .75x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_25 = String(
help="Optional, for older browsers: the Youtube ID for the 1.25x speed video.",
display_name="Youtube ID for 1.25x speed",
help="Optional, for older browsers: the YouTube ID for the 1.25x speed video.",
display_name="YouTube ID for 1.25x speed",
scope=Scope.settings,
default=""
)
youtube_id_1_5 = String(
help="Optional, for older browsers: the Youtube ID for the 1.5x speed video.",
display_name="Youtube ID for 1.5x speed",
help="Optional, for older browsers: the YouTube ID for the 1.5x speed video.",
display_name="YouTube ID for 1.5x speed",
scope=Scope.settings,
default=""
)
start_time = RelativeTime( # datetime.timedelta object
help="Start time for the video (HH:MM:SS). Max value is 23:59:59.",
display_name="Start Time",
help="Time you want the video to start if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59.",
display_name="Video Start Time",
scope=Scope.settings,
default=datetime.timedelta(seconds=0)
)
end_time = RelativeTime( # datetime.timedelta object
help="End time for the video (HH:MM:SS). Max value is 23:59:59.",
display_name="End Time",
help="Time you want the video to stop if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59.",
display_name="Video Stop Time",
scope=Scope.settings,
default=datetime.timedelta(seconds=0)
)
@@ -73,44 +73,44 @@ class VideoFields(object):
default=""
)
download_video = Boolean(
help="Show a link beneath the video to allow students to download the video. Note: You must add at least one video source below.",
help="Allow students to download versions of this video in different formats if they cannot use the edX video player or do not have access to YouTube. You must add at least one non-YouTube URL in the Video File URLs field.",
display_name="Video Download Allowed",
scope=Scope.settings,
default=False
)
html5_sources = List(
help="A list of filenames to be used with HTML5 video. The first supported filetype will be displayed.",
display_name="Video Sources",
help="The URL or URLs where youve posted non-YouTube versions of the video. Each URL must end in .mpeg, .mp4, .ogg, or .webm and cannot be a YouTube URL. Students will be able to view the first listed video that's compatible with the student's computer. To allow students to download these videos, set Video Download Allowed to True.",
display_name="Video File URLs",
scope=Scope.settings,
)
track = String(
help="The external URL to download the timed transcript track. This appears as a link beneath the video.",
display_name="Download Transcript",
help="By default, students can download an .srt or .txt transcript when you set Download Transcript Allowed to True. If you want to provide a downloadable transcript in a different format, we recommend that you upload a handout by using the Upload a Handout field. If this isn't possible, you can post a transcript file on the Files & Uploads page or on the Internet, and then add the URL for the transcript here. Students see a link to download that transcript below the video.",
display_name="Downloadable Transcript URL",
scope=Scope.settings,
default=''
)
download_track = Boolean(
help="Show a link beneath the video to allow students to download the transcript. Note: You must add a link to the HTML5 Transcript field above.",
display_name="Transcript Download Allowed",
help="Allow students to download the timed transcript. A link to download the file appears below the video. By default, the transcript is an .srt or .txt file. If you want to provide the transcript for download in a different format, upload a file by using the Upload Handout field.",
display_name="Download Transcript Allowed",
scope=Scope.settings,
default=False
)
sub = String(
help="The name of the timed transcript track (for non-Youtube videos).",
display_name="Transcript (primary)",
help="The default transcript for the video, from the Default Timed Transcript field on the Basic tab. This transcript should be in English. You don't have to change this setting.",
display_name="Default Timed Transcript",
scope=Scope.settings,
default=""
)
show_captions = Boolean(
help="This controls whether or not captions are shown by default.",
display_name="Transcript Display",
help="Specify whether the transcripts appear with the video by default.",
display_name="Show Transcript",
scope=Scope.settings,
default=True
)
# Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'}
transcripts = Dict(
help="Add additional transcripts in other languages.",
display_name="Transcript Translations",
help="Add transcripts in different languages. Click below to specify a language and upload an .srt transcript file for that language.",
display_name="Transcript Languages",
scope=Scope.settings,
default={}
)
@@ -131,16 +131,16 @@ class VideoFields(object):
default='srt',
)
speed = Float(
help="The last speed that was explicitly set by user for the video.",
help="The last speed that the user specified for the video.",
scope=Scope.user_state,
)
global_speed = Float(
help="Default speed in cases when speed wasn't explicitly for specific video.",
help="The default speed for the video.",
scope=Scope.preferences,
default=1.0
)
youtube_is_available = Boolean(
help="The availaibility of YouTube API for the user.",
help="Specify whether YouTube is available for the user.",
scope=Scope.user_info,
default=True
)

View File

@@ -1119,7 +1119,7 @@ class XMLParsingSystem(DescriptorSystem):
self.process_xml = process_xml
class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
"""
This is an abstraction such that x_modules can function independent
of the courseware (e.g. import into other types of courseware, LMS,
@@ -1139,7 +1139,7 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
open_ended_grading_interface=None, s3_interface=None,
cache=None, can_execute_unsafe_code=None, replace_course_urls=None,
replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None,
field_data=None, get_user_role=None,
field_data=None, get_user_role=None, rebind_noauth_module_to_user=None,
**kwargs):
"""
Create a closure around the system environment.
@@ -1198,6 +1198,9 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
for LMS and Studio.
field_data - the `FieldData` to use for backing XBlock storage.
rebind_noauth_module_to_user - rebinds module bound to AnonymousUser to a real user...used in LTI
modules, which have an anonymous handler, to set legitimate users' data
"""
# Usage_store is unused, and field_data is often supplanted with an
@@ -1236,6 +1239,7 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
self.get_user_role = get_user_role
self.descriptor_runtime = descriptor_runtime
self.rebind_noauth_module_to_user = rebind_noauth_module_to_user
def get(self, attr):
""" provide uniform access to attributes (like etree)."""

View File

@@ -141,8 +141,6 @@ describe 'ResponseCommentView', ->
spyOn(@view, 'cancelEdit')
spyOn($, "ajax").andCallFake(
(params) =>
expect(params.url._parts.path).toEqual("/courses/edX/999/test/discussion/comments/01234567/update")
expect(params.data.body).toEqual(@updatedBody)
if @ajaxSucceed
params.success()
else
@@ -154,6 +152,8 @@ describe 'ResponseCommentView', ->
@ajaxSucceed = true
@view.update(makeEventSpy())
expect($.ajax).toHaveBeenCalled()
expect($.ajax.mostRecentCall.args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/update')
expect($.ajax.mostRecentCall.args[0].data.body).toEqual(@updatedBody)
expect(@view.model.get("body")).toEqual(@updatedBody)
expect(@view.cancelEdit).toHaveBeenCalled()
@@ -162,6 +162,8 @@ describe 'ResponseCommentView', ->
@ajaxSucceed = false
@view.update(makeEventSpy())
expect($.ajax).toHaveBeenCalled()
expect($.ajax.mostRecentCall.args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/update')
expect($.ajax.mostRecentCall.args[0].data.body).toEqual(@updatedBody)
expect(@view.model.get("body")).toEqual(originalBody)
expect(@view.cancelEdit).not.toHaveBeenCalled()
expect(@view.$(".edit-comment-form-errors *").length).toEqual(1)

View File

@@ -0,0 +1,312 @@
/*!
* jQuery Simulate v@VERSION - simulate browser mouse and keyboard events
* https://github.com/jquery/jquery-simulate
*
* Copyright 2012 jQuery Foundation and other contributors
* Released under the MIT license.
* http://jquery.org/license
*
* Date: @DATE
*/
;(function( $, undefined ) {
var rkeyEvent = /^key/,
rmouseEvent = /^(?:mouse|contextmenu)|click/;
$.fn.simulate = function( type, options ) {
return this.each(function() {
new $.simulate( this, type, options );
});
};
$.simulate = function( elem, type, options ) {
var method = $.camelCase( "simulate-" + type );
this.target = elem;
this.options = options;
if ( this[ method ] ) {
this[ method ]();
} else {
this.simulateEvent( elem, type, options );
}
};
$.extend( $.simulate, {
keyCode: {
BACKSPACE: 8,
COMMA: 188,
DELETE: 46,
DOWN: 40,
END: 35,
ENTER: 13,
ESCAPE: 27,
HOME: 36,
LEFT: 37,
NUMPAD_ADD: 107,
NUMPAD_DECIMAL: 110,
NUMPAD_DIVIDE: 111,
NUMPAD_ENTER: 108,
NUMPAD_MULTIPLY: 106,
NUMPAD_SUBTRACT: 109,
PAGE_DOWN: 34,
PAGE_UP: 33,
PERIOD: 190,
RIGHT: 39,
SPACE: 32,
TAB: 9,
UP: 38
},
buttonCode: {
LEFT: 0,
MIDDLE: 1,
RIGHT: 2
}
});
$.extend( $.simulate.prototype, {
simulateEvent: function( elem, type, options ) {
var event = this.createEvent( type, options );
this.dispatchEvent( elem, type, event, options );
},
createEvent: function( type, options ) {
if ( rkeyEvent.test( type ) ) {
return this.keyEvent( type, options );
}
if ( rmouseEvent.test( type ) ) {
return this.mouseEvent( type, options );
}
},
mouseEvent: function( type, options ) {
var event, eventDoc, doc, body;
options = $.extend({
bubbles: true,
cancelable: (type !== "mousemove"),
view: window,
detail: 0,
screenX: 0,
screenY: 0,
clientX: 1,
clientY: 1,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
button: 0,
relatedTarget: undefined
}, options );
if ( document.createEvent ) {
event = document.createEvent( "MouseEvents" );
event.initMouseEvent( type, options.bubbles, options.cancelable,
options.view, options.detail,
options.screenX, options.screenY, options.clientX, options.clientY,
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey,
options.button, options.relatedTarget || document.body.parentNode );
// IE 9+ creates events with pageX and pageY set to 0.
// Trying to modify the properties throws an error,
// so we define getters to return the correct values.
if ( event.pageX === 0 && event.pageY === 0 && Object.defineProperty ) {
eventDoc = event.relatedTarget.ownerDocument || document;
doc = eventDoc.documentElement;
body = eventDoc.body;
Object.defineProperty( event, "pageX", {
get: function() {
return options.clientX +
( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) -
( doc && doc.clientLeft || body && body.clientLeft || 0 );
}
});
Object.defineProperty( event, "pageY", {
get: function() {
return options.clientY +
( doc && doc.scrollTop || body && body.scrollTop || 0 ) -
( doc && doc.clientTop || body && body.clientTop || 0 );
}
});
}
} else if ( document.createEventObject ) {
event = document.createEventObject();
$.extend( event, options );
// standards event.button uses constants defined here: http://msdn.microsoft.com/en-us/library/ie/ff974877(v=vs.85).aspx
// old IE event.button uses constants defined here: http://msdn.microsoft.com/en-us/library/ie/ms533544(v=vs.85).aspx
// so we actually need to map the standard back to oldIE
event.button = {
0: 1,
1: 4,
2: 2
}[ event.button ] || event.button;
}
return event;
},
keyEvent: function( type, options ) {
var event;
options = $.extend({
bubbles: true,
cancelable: true,
view: window,
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
keyCode: 0,
charCode: undefined
}, options );
if ( document.createEvent ) {
try {
event = document.createEvent( "KeyEvents" );
event.initKeyEvent( type, options.bubbles, options.cancelable, options.view,
options.ctrlKey, options.altKey, options.shiftKey, options.metaKey,
options.keyCode, options.charCode );
// initKeyEvent throws an exception in WebKit
// see: http://stackoverflow.com/questions/6406784/initkeyevent-keypress-only-works-in-firefox-need-a-cross-browser-solution
// and also https://bugs.webkit.org/show_bug.cgi?id=13368
// fall back to a generic event until we decide to implement initKeyboardEvent
} catch( err ) {
event = document.createEvent( "Events" );
event.initEvent( type, options.bubbles, options.cancelable );
$.extend( event, {
view: options.view,
ctrlKey: options.ctrlKey,
altKey: options.altKey,
shiftKey: options.shiftKey,
metaKey: options.metaKey,
keyCode: options.keyCode,
charCode: options.charCode
});
}
} else if ( document.createEventObject ) {
event = document.createEventObject();
$.extend( event, options );
}
if ( !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ) || (({}).toString.call( window.opera ) === "[object Opera]") ) {
event.keyCode = (options.charCode > 0) ? options.charCode : options.keyCode;
event.charCode = undefined;
}
return event;
},
dispatchEvent: function( elem, type, event ) {
if ( elem.dispatchEvent ) {
elem.dispatchEvent( event );
} else if ( elem.fireEvent ) {
elem.fireEvent( "on" + type, event );
}
},
simulateFocus: function() {
var focusinEvent,
triggered = false,
element = $( this.target );
function trigger() {
triggered = true;
}
element.bind( "focus", trigger );
element[ 0 ].focus();
if ( !triggered ) {
focusinEvent = $.Event( "focusin" );
focusinEvent.preventDefault();
element.trigger( focusinEvent );
element.triggerHandler( "focus" );
}
element.unbind( "focus", trigger );
},
simulateBlur: function() {
var focusoutEvent,
triggered = false,
element = $( this.target );
function trigger() {
triggered = true;
}
element.bind( "blur", trigger );
element[ 0 ].blur();
// blur events are async in IE
setTimeout(function() {
// IE won't let the blur occur if the window is inactive
if ( element[ 0 ].ownerDocument.activeElement === element[ 0 ] ) {
element[ 0 ].ownerDocument.body.focus();
}
// Firefox won't trigger events if the window is inactive
// IE doesn't trigger events if we had to manually focus the body
if ( !triggered ) {
focusoutEvent = $.Event( "focusout" );
focusoutEvent.preventDefault();
element.trigger( focusoutEvent );
element.triggerHandler( "blur" );
}
element.unbind( "blur", trigger );
}, 1 );
}
});
/** complex events **/
function findCenter( elem ) {
var offset,
document = $( elem.ownerDocument );
elem = $( elem );
offset = elem.offset();
return {
x: offset.left + elem.outerWidth() / 2 - document.scrollLeft(),
y: offset.top + elem.outerHeight() / 2 - document.scrollTop()
};
}
$.extend( $.simulate.prototype, {
simulateDrag: function() {
var i = 0,
target = this.target,
options = this.options,
center = findCenter( target ),
x = Math.floor( center.x ),
y = Math.floor( center.y ),
dx = options.dx || 0,
dy = options.dy || 0,
moves = options.moves || 3,
coord = { clientX: x, clientY: y };
this.simulateEvent( target, "mousedown", coord );
for ( ; i < moves ; i++ ) {
x += dx / moves;
y += dy / moves;
coord = {
clientX: Math.round( x ),
clientY: Math.round( y )
};
this.simulateEvent( document, "mousemove", coord );
}
this.simulateEvent( target, "mouseup", coord );
this.simulateEvent( target, "click", coord );
}
});
})( jQuery );

View File

@@ -102,7 +102,6 @@ class CourseNavPage(PageObject):
self.q(css=subsection_css).first.click()
self._on_section_promise(section_title, subsection_title).fulfill()
def go_to_sequential(self, sequential_title):
"""
Within a section/subsection, navigate to the sequential with `sequential_title`.

View File

@@ -0,0 +1,84 @@
"""
Staff view of courseware
"""
from bok_choy.page_object import PageObject
class StaffPage(PageObject):
"""
View of courseware pages while logged in as course staff
"""
url = None
def is_browser_on_page(self):
return self.q(css='#staffstatus').present
@property
def staff_status(self):
"""
Return the current status, either Staff view or Student view
"""
return self.q(css='#staffstatus').text[0]
def open_staff_debug_info(self):
"""
Open the staff debug window
Return the page object for it.
"""
self.q(css='a.instructor-info-action').first.click()
staff_debug_page = StaffDebugPage(self.browser)
staff_debug_page.wait_for_page()
return staff_debug_page
def answer_problem(self):
"""
Answers the problem to give state that we can clean
"""
self.q(css='input.check').first.click()
self.wait_for_ajax()
class StaffDebugPage(PageObject):
"""
Staff Debug modal
"""
url = None
def is_browser_on_page(self):
return self.q(css='section.staff-modal').present
def reset_attempts(self, user=None):
"""
This clicks on the reset attempts link with an optionally
specified user.
"""
if user:
self.q(css='input[id^=sd_fu_]').first.fill(user)
self.q(css='section.staff-modal a#staff-debug-reset').click()
def delete_state(self, user=None):
"""
This delete's a student's state for the problem
"""
if user:
self.q(css='input[id^=sd_fu_]').fill(user)
self.q(css='section.staff-modal a#staff-debug-sdelete').click()
def rescore(self, user=None):
"""
This clicks on the reset attempts link with an optionally
specified user.
"""
if user:
self.q(css='input[id^=sd_fu_]').first.fill(user)
self.q(css='section.staff-modal a#staff-debug-rescore').click()
@property
def idash_msg(self):
"""
Returns the value of #idash_msg
"""
self.wait_for_ajax()
return self.q(css='#idash_msg').text

View File

@@ -8,7 +8,6 @@ from selenium.webdriver.common.action_chains import ActionChains
from bok_choy.page_object import PageObject
from bok_choy.promise import EmptyPromise, Promise
from bok_choy.javascript import wait_for_js, js_defined
from ...tests.helpers import wait_for_ajax
VIDEO_BUTTONS = {
@@ -18,6 +17,7 @@ VIDEO_BUTTONS = {
'pause': '.video_control.pause',
'fullscreen': '.add-fullscreen',
'download_transcript': '.video-tracks > a',
'speed': '.speeds'
}
CSS_CLASS_NAMES = {
@@ -31,12 +31,14 @@ CSS_CLASS_NAMES = {
'video_spinner': '.video-wrapper .spinner',
'video_xmodule': '.xmodule_VideoModule',
'video_init': '.is-initialized',
'video_time': 'div.vidtime'
'video_time': 'div.vidtime',
'video_display_name': '.vert h2',
'captions_lang_list': '.langs-list li'
}
VIDEO_MODES = {
'html5': 'video',
'youtube': 'iframe'
'html5': 'div.video video',
'youtube': 'div.video iframe'
}
VIDEO_MENUS = {
@@ -60,20 +62,25 @@ class VideoPage(PageObject):
return self.q(css='div{0}'.format(CSS_CLASS_NAMES['video_xmodule'])).present
@wait_for_js
def _wait_for_element(self, element_css_selector, promise_desc):
"""
Wait for element specified by `element_css_selector` is present in DOM.
:param element_css_selector: css selector of the element
:param promise_desc: Description of the Promise, used in log messages.
:return: BrokenPromise: the `Promise` was not satisfied within the time or attempt limits.
# TODO(muhammad-ammar) Move this function to somewhere else so that others can use it also. # pylint: disable=W0511
def _wait_for_element(self, element_selector, promise_desc):
"""
Wait for element specified by `element_selector` is present in DOM.
Arguments:
element_selector (str): css selector of the element.
promise_desc (str): Description of the Promise, used in log messages.
"""
def _is_element_present():
"""
Check if web-element present in DOM
:return: bool
Check if web-element present in DOM.
Returns:
bool: Tells elements presence.
"""
return self.q(css=element_css_selector).present
return self.q(css=element_selector).present
EmptyPromise(_is_element_present, promise_desc, timeout=200).fulfill()
@@ -81,16 +88,18 @@ class VideoPage(PageObject):
def wait_for_video_class(self):
"""
Wait until element with class name `video` appeared in DOM.
"""
wait_for_ajax(self.browser)
video_css = '{0}'.format(CSS_CLASS_NAMES['video_container'])
self._wait_for_element(video_css, 'Video is initialized')
"""
self.wait_for_ajax()
video_selector = '{0}'.format(CSS_CLASS_NAMES['video_container'])
self._wait_for_element(video_selector, 'Video is initialized')
@wait_for_js
def wait_for_video_player_render(self):
"""
Wait until Video Player Rendered Completely.
"""
self.wait_for_video_class()
self._wait_for_element(CSS_CLASS_NAMES['video_init'], 'Video Player Initialized')
@@ -98,39 +107,95 @@ class VideoPage(PageObject):
def _is_finished_loading():
"""
Check if video loading completed
:return: bool
Check if video loading completed.
Returns:
bool: Tells Video Finished Loading.
"""
return not self.q(css=CSS_CLASS_NAMES['video_spinner']).visible
EmptyPromise(_is_finished_loading, 'Finished loading the video', timeout=200).fulfill()
wait_for_ajax(self.browser)
self.wait_for_ajax()
def is_video_rendered(self, mode):
def get_video_vertical_selector(self, video_display_name=None):
"""
Get selector for a video vertical with display name specified by `video_display_name`.
Arguments:
video_display_name (str or None): Display name of a Video. Default vertical selector if None.
Returns:
str: Vertical Selector for video.
"""
if video_display_name:
video_display_names = self.q(css=CSS_CLASS_NAMES['video_display_name']).text
if video_display_name not in video_display_names:
raise ValueError("Incorrect Video Display Name: '{0}'".format(video_display_name))
return '.vert.vert-{}'.format(video_display_names.index(video_display_name))
else:
return '.vert.vert-0'
def get_element_selector(self, video_display_name, class_name):
"""
Construct unique element selector.
Arguments:
video_display_name (str or None): Display name of a Video.
class_name (str): css class name for an element.
Returns:
str: Element Selector.
"""
return '{vertical} {video_element}'.format(
vertical=self.get_video_vertical_selector(video_display_name),
video_element=class_name)
def is_video_rendered(self, mode, video_display_name=None):
"""
Check that if video is rendered in `mode`.
:param mode: Video mode, `html5` or `youtube`
Arguments:
mode (str): Video mode, `html5` or `youtube`.
video_display_name (str or None): Display name of a Video.
Returns:
bool: Tells if video is rendered in `mode`.
"""
html_tag = VIDEO_MODES[mode]
css = '{0} {1}'.format(CSS_CLASS_NAMES['video_container'], html_tag)
selector = self.get_element_selector(video_display_name, VIDEO_MODES[mode])
def _is_element_present():
"""
Check if a web element is present in DOM
:return:
Check if a web element is present in DOM.
Returns:
tuple: (is_satisfied, result)`, where `is_satisfied` is a boolean indicating whether the promise was
satisfied, and `result` is a value to return from the fulfilled `Promise`.
"""
is_present = self.q(css=css).present
is_present = self.q(css=selector).present
return is_present, is_present
return Promise(_is_element_present, 'Video Rendering Failed in {0} mode.'.format(mode)).fulfill()
@property
def is_autoplay_enabled(self):
def is_autoplay_enabled(self, video_display_name=None):
"""
Extract `data-autoplay` attribute to check video autoplay is enabled or disabled.
Arguments:
video_display_name (str or None): Display name of a Video.
Returns:
bool: Tells if autoplay enabled/disabled.
"""
auto_play = self.q(css=CSS_CLASS_NAMES['video_container']).attrs('data-autoplay')[0]
selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['video_container'])
auto_play = self.q(css=selector).attrs('data-autoplay')[0]
if auto_play.lower() == 'false':
return False
@@ -138,129 +203,236 @@ class VideoPage(PageObject):
return True
@property
def is_error_message_shown(self):
def is_error_message_shown(self, video_display_name=None):
"""
Checks if video player error message shown.
:return: bool
Arguments:
video_display_name (str or None): Display name of a Video.
Returns:
bool: Tells about error message visibility.
"""
return self.q(css=CSS_CLASS_NAMES['error_message']).visible
selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['error_message'])
return self.q(css=selector).visible
@property
def error_message_text(self):
def error_message_text(self, video_display_name=None):
"""
Extract video player error message text.
:return: str
"""
return self.q(css=CSS_CLASS_NAMES['error_message']).text[0]
def is_button_shown(self, button_id):
Arguments:
video_display_name (str or None): Display name of a Video.
Returns:
str: Error message text.
"""
Check if a video button specified by `button_id` is visible
:param button_id: button css selector
:return: bool
selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['error_message'])
return self.q(css=selector).text[0]
def is_button_shown(self, button_id, video_display_name=None):
"""
return self.q(css=VIDEO_BUTTONS[button_id]).visible
Check if a video button specified by `button_id` is visible.
Arguments:
button_id (str): key in VIDEO_BUTTONS dictionary, its value will give us the css selector for button.
video_display_name (str or None): Display name of a Video.
Returns:
bool: Tells about a buttons visibility.
"""
selector = self.get_element_selector(video_display_name, VIDEO_BUTTONS[button_id])
return self.q(css=selector).visible
def show_captions(self, video_display_name=None):
"""
Make Captions Visible.
Arguments:
video_display_name (str or None): Display name of a Video.
"""
self._captions_visibility(True, video_display_name)
def hide_captions(self, video_display_name=None):
"""
Make Captions Invisible.
Arguments:
video_display_name (str or None): Display name of a Video.
"""
self._captions_visibility(False, video_display_name)
@wait_for_js
def show_captions(self):
"""
Show the video captions.
def _captions_visibility(self, captions_new_state, video_display_name=None):
"""
Set the video captions visibility state.
def _is_subtitles_open():
Arguments:
video_display_name (str or None): Display name of a Video.
captions_new_state (bool): True means show captions, False means hide captions
"""
states = {True: 'Shown', False: 'Hidden'}
state = states[captions_new_state]
caption_state_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['closed_captions'])
def _captions_current_state():
"""
Check if subtitles are opened
:return: bool
Get current visibility sate of captions.
Returns:
bool: True means captions are visible, False means captions are not visible
"""
is_open = not self.q(css=CSS_CLASS_NAMES['closed_captions']).present
return is_open
return not self.q(css=caption_state_selector).present
# Make sure that the CC button is there
EmptyPromise(lambda: self.is_button_shown('CC'),
"CC button is shown").fulfill()
# Check if the captions are already open and click if not
if _is_subtitles_open() is False:
self.q(css=VIDEO_BUTTONS['CC']).first.click()
# toggle captions visibility state if needed
if _captions_current_state() != captions_new_state:
self.click_player_button('CC')
# Verify that they are now open
EmptyPromise(_is_subtitles_open,
"Subtitles are shown").fulfill()
# Verify that captions state is toggled/changed
EmptyPromise(lambda: _captions_current_state() == captions_new_state,
"Captions are {state}".format(state=state)).fulfill()
@property
def captions_text(self):
def captions_text(self, video_display_name=None):
"""
Extract captions text.
:return: str
Arguments:
video_display_name (str or None): Display name of a Video.
Returns:
str: Captions Text.
"""
# wait until captions rendered completely
self._wait_for_element(CSS_CLASS_NAMES['captions_rendered'], 'Captions Rendered')
captions_rendered_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['captions_rendered'])
self._wait_for_element(captions_rendered_selector, 'Captions Rendered')
captions_css = CSS_CLASS_NAMES['captions_text']
subs = self.q(css=captions_css).html
captions_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['captions_text'])
subs = self.q(css=captions_selector).html
return ' '.join(subs)
def set_speed(self, speed):
def set_speed(self, speed, video_display_name=None):
"""
Change the video play speed.
:param speed: speed value in str
Arguments:
speed (str): Video speed value
video_display_name (str or None): Display name of a Video.
"""
self.browser.execute_script("$('.speeds').addClass('is-opened')")
speed_css = 'li[data-speed="{0}"] a'.format(speed)
# mouse over to video speed button
speed_menu_selector = self.get_element_selector(video_display_name, VIDEO_BUTTONS['speed'])
element_to_hover_over = self.q(css=speed_menu_selector).results[0]
hover = ActionChains(self.browser).move_to_element(element_to_hover_over)
hover.perform()
EmptyPromise(lambda: self.q(css='.speeds').visible, 'Video Speed Control Shown').fulfill()
speed_selector = self.get_element_selector(video_display_name, 'li[data-speed="{speed}"] a'.format(speed=speed))
self.q(css=speed_selector).first.click()
self.q(css=speed_css).first.click()
def get_speed(self):
"""
Get current video speed value.
:return: str
"""
speed_css = '.speeds .value'
return self.q(css=speed_css).text[0]
speed = property(get_speed, set_speed)
def click_player_button(self, button):
def click_player_button(self, button, video_display_name=None):
"""
Click on `button`.
:param button: key in VIDEO_BUTTONS dictionary, its value will give us the css selector for `button`
Arguments:
button (str): key in VIDEO_BUTTONS dictionary, its value will give us the css selector for `button`
video_display_name (str or None): Display name of a Video.
"""
self.q(css=VIDEO_BUTTONS[button]).first.click()
wait_for_ajax(self.browser)
button_selector = self.get_element_selector(video_display_name, VIDEO_BUTTONS[button])
self.q(css=button_selector).first.click()
if button == 'play':
# wait for video buffering
self._wait_for_video_play(video_display_name)
self.wait_for_ajax()
def _wait_for_video_play(self, video_display_name=None):
"""
Wait until video starts playing
Arguments:
video_display_name (str or None): Display name of a Video.
"""
playing_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['video_container'])
pause_selector = self.get_element_selector(video_display_name, VIDEO_BUTTONS['pause'])
def _check_promise():
"""
Promise check
Returns:
bool: Is promise satisfied.
"""
return 'is-playing' in self.q(css=playing_selector).attrs('class')[0] and self.q(css=pause_selector).present
EmptyPromise(_check_promise, 'Video is Playing', timeout=200).fulfill()
def _get_element_dimensions(self, selector):
"""
Gets the width and height of element specified by `selector`
:param selector: str, css selector of a web element
:return: dict
Arguments:
selector (str): css selector of a web element
Returns:
dict: Dimensions of a web element.
"""
element = self.q(css=selector).results[0]
return element.size
def _get_dimensions(self):
def _get_dimensions(self, video_display_name=None):
"""
Gets the video player dimensions
:return: tuple
Gets the video player dimensions.
Arguments:
video_display_name (str or None): Display name of a Video.
Returns:
tuple: Dimensions
"""
video = self._get_element_dimensions('.video-player iframe, .video-player video')
wrapper = self._get_element_dimensions('.tc-wrapper')
controls = self._get_element_dimensions('.video-controls')
progress_slider = self._get_element_dimensions('.video-controls > .slider')
iframe_selector = self.get_element_selector(video_display_name, '.video-player iframe,')
video_selector = self.get_element_selector(video_display_name, ' .video-player video')
video = self._get_element_dimensions(iframe_selector + video_selector)
wrapper = self._get_element_dimensions(self.get_element_selector(video_display_name, '.tc-wrapper'))
controls = self._get_element_dimensions(self.get_element_selector(video_display_name, '.video-controls'))
progress_slider = self._get_element_dimensions(
self.get_element_selector(video_display_name, '.video-controls > .slider'))
expected = dict(wrapper)
expected['height'] -= controls['height'] + 0.5 * progress_slider['height']
return video, expected
def is_aligned(self, is_transcript_visible):
def is_aligned(self, is_transcript_visible, video_display_name=None):
"""
Check if video is aligned properly.
:param is_transcript_visible: bool
:return: bool
Arguments:
is_transcript_visible (bool): Transcript is visible or not.
video_display_name (str or None): Display name of a Video.
Returns:
bool: Alignment result.
"""
# Width of the video container in css equal 75% of window if transcript enabled
wrapper_width = 75 if is_transcript_visible else 100
@@ -272,7 +444,7 @@ class VideoPage(PageObject):
# Currently there is no other way to wait instead of explicit wait
time.sleep(0.2)
real, expected = self._get_dimensions()
real, expected = self._get_dimensions(video_display_name)
width = round(100 * real['width'] / expected['width']) == wrapper_width
@@ -282,7 +454,7 @@ class VideoPage(PageObject):
# Currently there is no other way to wait instead of explicit wait
time.sleep(0.2)
real, expected = self._get_dimensions()
real, expected = self._get_dimensions(video_display_name)
height = abs(expected['height'] - real['height']) <= 5
@@ -295,7 +467,8 @@ class VideoPage(PageObject):
def _get_transcript(self, url):
"""
Sends a http get request.
Download Transcript from `url`
"""
kwargs = dict()
@@ -308,53 +481,171 @@ class VideoPage(PageObject):
response = requests.get(url, **kwargs)
return response.status_code < 400, response.headers, response.content
def downloaded_transcript_contains_text(self, transcript_format, text_to_search):
def downloaded_transcript_contains_text(self, transcript_format, text_to_search, video_display_name=None):
"""
Download the transcript in format `transcript_format` and check that it contains the text `text_to_search`
:param transcript_format: `srt` or `txt`
:param text_to_search: str
:return: bool
Arguments:
transcript_format (str): Transcript file format `srt` or `txt`
text_to_search (str): Text to search in Transcript.
video_display_name (str or None): Display name of a Video.
Returns:
bool: Transcript download result.
"""
transcript_selector = self.get_element_selector(video_display_name, VIDEO_MENUS['transcript-format'])
# check if we have a transcript with correct format
assert '.' + transcript_format in self.q(css=VIDEO_MENUS['transcript-format']).text[0]
if '.' + transcript_format not in self.q(css=transcript_selector).text[0]:
return False
formats = {
'srt': 'application/x-subrip',
'txt': 'text/plain',
}
url = self.q(css=VIDEO_BUTTONS['download_transcript']).attrs('href')[0]
transcript_url_selector = self.get_element_selector(video_display_name, VIDEO_BUTTONS['download_transcript'])
url = self.q(css=transcript_url_selector).attrs('href')[0]
result, headers, content = self._get_transcript(url)
assert result
assert formats[transcript_format] in headers.get('content-type', '')
assert text_to_search in content.decode('utf-8')
if result is False:
return False
def select_language(self, code):
"""
Select captions for language `code`
:param code: str, two character language code like `en`, `zh`
:return: bool, True for Success, False for Failure or BrokenPromise
"""
wait_for_ajax(self.browser)
if formats[transcript_format] not in headers.get('content-type', ''):
return False
selector = VIDEO_MENUS["language"] + ' li[data-lang-code="{code}"]'.format(code=code)
if text_to_search not in content.decode('utf-8'):
return False
return True
def select_language(self, code, video_display_name=None):
"""
Select captions for language `code`.
Arguments:
code (str): two character language code like `en`, `zh`.
video_display_name (str or None): Display name of a Video.
"""
self.wait_for_ajax()
# mouse over to CC button
element_to_hover_over = self.q(css=VIDEO_BUTTONS["CC"]).results[0]
cc_button_selector = self.get_element_selector(video_display_name, VIDEO_BUTTONS["CC"])
element_to_hover_over = self.q(css=cc_button_selector).results[0]
hover = ActionChains(self.browser).move_to_element(element_to_hover_over)
hover.perform()
self.q(css=selector).first.click()
language_selector = VIDEO_MENUS["language"] + ' li[data-lang-code="{code}"]'.format(code=code)
language_selector = self.get_element_selector(video_display_name, language_selector)
self.q(css=language_selector).first.click()
assert 'is-active' == self.q(css=selector).attrs('class')[0]
assert len(self.q(css=VIDEO_MENUS["language"] + ' li.is-active').results) == 1
if 'is-active' != self.q(css=language_selector).attrs('class')[0]:
return False
active_lang_selector = self.get_element_selector(video_display_name, VIDEO_MENUS["language"] + ' li.is-active')
if len(self.q(css=active_lang_selector).results) != 1:
return False
# Make sure that all ajax requests that affects the display of captions are finished.
# For example, request to get new translation etc.
wait_for_ajax(self.browser)
self.wait_for_ajax()
EmptyPromise(lambda: self.q(css=CSS_CLASS_NAMES['captions']).visible, 'Subtitles Visible').fulfill()
captions_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['captions'])
EmptyPromise(lambda: self.q(css=captions_selector).visible, 'Subtitles Visible').fulfill()
# wait until captions rendered completely
self._wait_for_element(CSS_CLASS_NAMES['captions_rendered'], 'Captions Rendered')
captions_rendered_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['captions_rendered'])
self._wait_for_element(captions_rendered_selector, 'Captions Rendered')
return True
def is_menu_exist(self, menu_name, video_display_name=None):
"""
Check if menu `menu_name` exists.
Arguments:
menu_name (str): Menu key from VIDEO_MENUS.
video_display_name (str or None): Display name of a Video.
Returns:
bool: Menu existence result
"""
selector = self.get_element_selector(video_display_name, VIDEO_MENUS[menu_name])
return self.q(css=selector).present
def select_transcript_format(self, transcript_format, video_display_name=None):
"""
Select transcript with format `transcript_format`.
Arguments:
transcript_format (st): Transcript file format `srt` or `txt`.
video_display_name (str or None): Display name of a Video.
Returns:
bool: Selection Result.
"""
button_selector = self.get_element_selector(video_display_name, VIDEO_MENUS['transcript-format'])
button = self.q(css=button_selector).results[0]
coord_y = button.location_once_scrolled_into_view['y']
self.browser.execute_script("window.scrollTo(0, {});".format(coord_y))
hover = ActionChains(self.browser).move_to_element(button)
hover.perform()
if '...' not in self.q(css=button_selector).text[0]:
return False
menu_selector = self.get_element_selector(video_display_name, VIDEO_MENUS['download_transcript'])
menu_items = self.q(css=menu_selector + ' a').results
for item in menu_items:
if item.get_attribute('data-value') == transcript_format:
item.click()
self.wait_for_ajax()
break
self.browser.execute_script("window.scrollTo(0, 0);")
if self.q(css=menu_selector + ' .active a').attrs('data-value')[0] != transcript_format:
return False
if '.' + transcript_format not in self.q(css=button_selector).text[0]:
return False
return True
def sources(self, video_display_name=None):
"""
Extract all video source urls on current page.
Arguments:
video_display_name (str or None): Display name of a Video.
Returns:
list: Video Source URLs.
"""
sources_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['video_sources'])
return self.q(css=sources_selector).map(lambda el: el.get_attribute('src').split('?')[0]).results
def caption_languages(self, video_display_name=None):
"""
Get caption languages available for a video.
Arguments:
video_display_name (str or None): Display name of a Video.
Returns:
dict: Language Codes('en', 'zh' etc) as keys and Language Names as Values('English', 'Chinese' etc)
"""
languages_selector = self.get_element_selector(video_display_name, CSS_CLASS_NAMES['captions_lang_list'])
language_codes = self.q(css=languages_selector).attrs('data-lang-code')
language_names = self.q(css=languages_selector).attrs('textContent')
return dict(zip(language_codes, language_names))

View File

@@ -6,6 +6,7 @@ from bok_choy.page_object import PageObject
from bok_choy.promise import Promise
from . import BASE_URL
from selenium.webdriver.common.action_chains import ActionChains
class ContainerPage(PageObject):
"""
@@ -44,6 +45,24 @@ class ContainerPage(PageObject):
return self.q(css=XBlockWrapper.BODY_SELECTOR).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
def drag(self, source_index, target_index, after=True):
"""
Gets the drag handle with index source_index (relative to the vertical layout of the page)
and drags it to the location of the drag handle with target_index.
This should drag the element with the source_index drag handle AFTER the
one with the target_index drag handle, unless 'after' is set to False.
"""
draggables = self.q(css='.drag-handle')
source = draggables[source_index]
target = draggables[target_index]
action = ActionChains(self.browser)
action.click_and_hold(source).perform() # pylint: disable=protected-access
action.move_to_element_with_offset(
target, 0, target.size['height'] / 2 if after else 0
).perform() # pylint: disable=protected-access
action.release().perform()
class XBlockWrapper(PageObject):
"""
@@ -78,6 +97,22 @@ class XBlockWrapper(PageObject):
else:
return None
@property
def children(self):
"""
Will return any first-generation descendant xblocks of this xblock.
"""
descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
# Now remove any non-direct descendants.
grandkids = []
for descendant in descendants:
grandkids.extend(descendant.children)
grand_locators = [grandkid.locator for grandkid in grandkids]
return [descendant for descendant in descendants if not descendant.locator in grand_locators]
@property
def preview_selector(self):
return self._bounded_selector('.xblock-student_view')

View File

@@ -6,26 +6,6 @@ from bok_choy.web_app_test import WebAppTest
from bok_choy.promise import EmptyPromise
def wait_for_ajax(browser, try_limit=None, try_interval=0.5, timeout=60):
"""
Make sure that all ajax requests are finished.
:param try_limit (int or None): Number of attempts to make to satisfy the `Promise`. Can be `None` to
disable the limit.
:param try_interval (float): Number of seconds to wait between attempts.
:param timeout (float): Maximum number of seconds to wait for the `Promise` to be satisfied before timing out.
:param browser: selenium.webdriver, The Selenium-controlled browser that this page is loaded in.
"""
def _is_ajax_finished():
"""
Check if all the ajax call on current page completed.
:return:
"""
return browser.execute_script("return jQuery.active") == 0
EmptyPromise(_is_ajax_finished, "Finished waiting for ajax requests.", try_limit=try_limit,
try_interval=try_interval, timeout=timeout).fulfill()
def load_data_str(rel_path):
"""
Load a file from the "data" directory as a string.

View File

@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
"""
E2E tests for the LMS.
"""
from .helpers import UniqueCourseTest
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.lms.courseware import CoursewarePage
from ..pages.lms.staff_view import StaffPage
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from textwrap import dedent
class StaffDebugTest(UniqueCourseTest):
"""
Tests that verify the staff debug info.
"""
USERNAME = "STAFF_TESTER"
EMAIL = "johndoe@example.com"
def setUp(self):
super(StaffDebugTest, self).setUp()
self.courseware_page = CoursewarePage(self.browser, self.course_id)
# Install a course with sections/problems, tabs, updates, and handouts
course_fix = CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
)
problem_data = dedent("""
<problem markdown="Simple Problem" max_attempts="" weight="">
<p>Choose Yes.</p>
<choiceresponse>
<checkboxgroup direction="vertical">
<choice correct="true">Yes</choice>
</checkboxgroup>
</choiceresponse>
</problem>
""")
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('problem', 'Test Problem 1', data=problem_data)
)
)
).install()
# Auto-auth register for the course.
# Do this as global staff so that you will see the Staff View
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
course_id=self.course_id, staff=True).visit()
def _goto_staff_page(self):
"""
Open staff page with assertion
"""
self.courseware_page.visit()
staff_page = StaffPage(self.browser)
self.assertEqual(staff_page.staff_status, 'Staff view')
return staff_page
def test_reset_attempts_empty(self):
"""
Test that we reset even when there is no student state
"""
staff_debug_page = self._goto_staff_page().open_staff_debug_info()
staff_debug_page.reset_attempts()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully reset the attempts '
'for user {}'.format(self.USERNAME), msg)
def test_delete_state_empty(self):
"""
Test that we delete properly even when there isn't state to delete.
"""
staff_debug_page = self._goto_staff_page().open_staff_debug_info()
staff_debug_page.delete_state()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state '
'for user {}'.format(self.USERNAME), msg)
def test_reset_attempts_state(self):
"""
Successfully reset the student attempts
"""
staff_page = self._goto_staff_page()
staff_page.answer_problem()
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.reset_attempts()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully reset the attempts '
'for user {}'.format(self.USERNAME), msg)
def test_rescore_state(self):
"""
Rescore the student
"""
staff_page = self._goto_staff_page()
staff_page.answer_problem()
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.rescore()
msg = staff_debug_page.idash_msg[0]
# Since we aren't running celery stuff, this will fail badly
# for now, but is worth excercising that bad of a response
self.assertEqual(u'Failed to rescore problem. '
'Unknown Error Occurred.', msg)
def test_student_state_delete(self):
"""
Successfully delete the student state with an answer
"""
staff_page = self._goto_staff_page()
staff_page.answer_problem()
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.delete_state()
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully deleted student state '
'for user {}'.format(self.USERNAME), msg)
def test_student_by_email(self):
"""
Successfully reset the student attempts using their email address
"""
staff_page = self._goto_staff_page()
staff_page.answer_problem()
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.reset_attempts(self.EMAIL)
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Successfully reset the attempts '
'for user {}'.format(self.EMAIL), msg)
def test_bad_student(self):
"""
Test negative response with invalid user
"""
staff_page = self._goto_staff_page()
staff_page.answer_problem()
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.delete_state('INVALIDUSER')
msg = staff_debug_page.idash_msg[0]
self.assertEqual(u'Failed to delete student state. '
'User does not exist.', msg)

View File

@@ -1,155 +1,15 @@
"""
Acceptance tests for Studio.
Acceptance tests for Studio related to the acid xblock.
"""
from unittest import skip
from bok_choy.web_app_test import WebAppTest
from ..pages.studio.asset_index import AssetIndexPage
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.checklists import ChecklistsPage
from ..pages.studio.course_import import ImportPage
from ..pages.studio.course_info import CourseUpdatesPage
from ..pages.studio.edit_tabs import PagesPage
from ..pages.studio.export import ExportPage
from ..pages.studio.howitworks import HowitworksPage
from ..pages.studio.index import DashboardPage
from ..pages.studio.login import LoginPage
from ..pages.studio.manage_users import CourseTeamPage
from ..pages.studio.overview import CourseOutlinePage
from ..pages.studio.settings import SettingsPage
from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_graders import GradingPage
from ..pages.studio.signup import SignupPage
from ..pages.studio.textbooks import TextbooksPage
from ..pages.xblock.acid import AcidView
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest
class LoggedOutTest(WebAppTest):
"""
Smoke test for pages in Studio that are visible when logged out.
"""
def setUp(self):
super(LoggedOutTest, self).setUp()
self.pages = [LoginPage(self.browser), HowitworksPage(self.browser), SignupPage(self.browser)]
def test_page_existence(self):
"""
Make sure that all the pages are accessible.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
for page in self.pages:
page.visit()
class LoggedInPagesTest(WebAppTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and do not have a course yet.
"""
def setUp(self):
super(LoggedInPagesTest, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.dashboard_page = DashboardPage(self.browser)
def test_dashboard_no_courses(self):
"""
Make sure that you can get to the dashboard page without a course.
"""
self.auth_page.visit()
self.dashboard_page.visit()
class CoursePagesTest(UniqueCourseTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and have a course.
"""
COURSE_ID_SEPARATOR = "."
def setUp(self):
"""
Install a course with no content using a fixture.
"""
super(UniqueCourseTest, self).setUp()
CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
).install()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.pages = [
clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'])
for clz in [
AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage,
PagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage,
AdvancedSettingsPage, GradingPage, TextbooksPage
]
]
def test_page_existence(self):
"""
Make sure that all these pages are accessible once you have a course.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
# Log in
self.auth_page.visit()
# Verify that each page is available
for page in self.pages:
page.visit()
class DiscussionPreviewTest(UniqueCourseTest):
"""
Tests that Inline Discussions are rendered with a custom preview in Studio
"""
def setUp(self):
super(DiscussionPreviewTest, self).setUp()
CourseFixture(**self.course_info).add_children(
XBlockFixtureDesc("chapter", "Test Section").add_children(
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
XBlockFixtureDesc("vertical", "Test Unit").add_children(
XBlockFixtureDesc(
"discussion",
"Test Discussion",
)
)
)
)
).install()
AutoAuthPage(self.browser, staff=True).visit()
cop = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
cop.visit()
self.unit = cop.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit')
self.unit.go_to()
def test_is_preview(self):
"""
Ensure that the preview version of the discussion is rendered.
"""
self.assertTrue(self.unit.q(css=".discussion-preview").present)
self.assertFalse(self.unit.q(css=".discussion-show").present)
class XBlockAcidBase(WebAppTest):
"""

View File

@@ -0,0 +1,163 @@
"""
Acceptance tests for Studio related to the container page.
"""
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.overview import CourseOutlinePage
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest
class ContainerBase(UniqueCourseTest):
"""
Base class for tests that do operations on the container page.
"""
__test__ = False
def setUp(self):
"""
Create a unique identifier for the course used in this test.
"""
# Ensure that the superclass sets up
super(ContainerBase, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.outline = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
self.container_title = ""
self.group_a = "Expand or Collapse\nGroup A"
self.group_b = "Expand or Collapse\nGroup B"
self.group_empty = "Expand or Collapse\nGroup Empty"
self.group_a_item_1 = "Group A Item 1"
self.group_a_item_2 = "Group A Item 2"
self.group_b_item_1 = "Group B Item 1"
self.group_b_item_2 = "Group B Item 2"
self.setup_fixtures()
self.auth_page.visit()
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit').add_children(
XBlockFixtureDesc('vertical', 'Test Container').add_children(
XBlockFixtureDesc('vertical', 'Group A').add_children(
XBlockFixtureDesc('html', self.group_a_item_1),
XBlockFixtureDesc('html', self.group_a_item_2)
),
XBlockFixtureDesc('vertical', 'Group Empty'),
XBlockFixtureDesc('vertical', 'Group B').add_children(
XBlockFixtureDesc('html', self.group_b_item_1),
XBlockFixtureDesc('html', self.group_b_item_2)
)
)
)
)
)
).install()
def go_to_container_page(self, make_draft=False):
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
if make_draft:
unit.edit_draft()
container = unit.components[0].go_to_container()
return container
class DragAndDropTest(ContainerBase):
"""
Tests of reordering within the container page.
"""
__test__ = True
def verify_ordering(self, container, expected_orderings):
xblocks = container.xblocks
for expected_ordering in expected_orderings:
for xblock in xblocks:
parent = expected_ordering.keys()[0]
if xblock.name == parent:
children = xblock.children
expected_length = len(expected_ordering.get(parent))
self.assertEqual(
expected_length, len(children),
"Number of children incorrect for group {0}. Expected {1} but got {2}.".format(parent, expected_length, len(children)))
for idx, expected in enumerate(expected_ordering.get(parent)):
self.assertEqual(expected, children[idx].name)
break
def drag_and_verify(self, source, target, expected_ordering, after=True):
container = self.go_to_container_page(make_draft=True)
container.drag(source, target, after)
self.verify_ordering(container, expected_ordering)
# Reload the page to see that the reordering was saved persisted.
container = self.go_to_container_page()
self.verify_ordering(container, expected_ordering)
def test_reorder_in_group(self):
"""
Drag Group B Item 2 before Group B Item 1.
"""
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_2, self.group_b_item_1]},
{self.group_empty: []}]
self.drag_and_verify(6, 4, expected_ordering)
def test_drag_to_top(self):
"""
Drag Group A Item 1 to top level (outside of Group A).
"""
expected_ordering = [{self.container_title: [self.group_a_item_1, self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.drag_and_verify(1, 0, expected_ordering, False)
def test_drag_into_different_group(self):
"""
Drag Group A Item 1 into Group B (last element).
"""
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2, self.group_a_item_1]},
{self.group_empty: []}]
self.drag_and_verify(1, 6, expected_ordering)
def test_drag_group_into_group(self):
"""
Drag Group B into Group A (last element).
"""
expected_ordering = [{self.container_title: [self.group_a, self.group_empty]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2, self.group_b]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.drag_and_verify(4, 2, expected_ordering)
# Not able to drag into the empty group with automation (difficult even outside of automation).
# def test_drag_into_empty(self):
# """
# Drag Group B Item 1 to Group Empty.
# """
# expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
# {self.group_a: [self.group_a_item_1, self.group_a_item_2]},
# {self.group_b: [self.group_b_item_2]},
# {self.group_empty: [self.group_b_item_1]}]
# self.drag_and_verify(6, 4, expected_ordering, False)

View File

@@ -0,0 +1,148 @@
"""
Acceptance tests for Studio.
"""
from bok_choy.web_app_test import WebAppTest
from ..pages.studio.asset_index import AssetIndexPage
from ..pages.studio.auto_auth import AutoAuthPage
from ..pages.studio.checklists import ChecklistsPage
from ..pages.studio.course_import import ImportPage
from ..pages.studio.course_info import CourseUpdatesPage
from ..pages.studio.edit_tabs import PagesPage
from ..pages.studio.export import ExportPage
from ..pages.studio.howitworks import HowitworksPage
from ..pages.studio.index import DashboardPage
from ..pages.studio.login import LoginPage
from ..pages.studio.manage_users import CourseTeamPage
from ..pages.studio.overview import CourseOutlinePage
from ..pages.studio.settings import SettingsPage
from ..pages.studio.settings_advanced import AdvancedSettingsPage
from ..pages.studio.settings_graders import GradingPage
from ..pages.studio.signup import SignupPage
from ..pages.studio.textbooks import TextbooksPage
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest
class LoggedOutTest(WebAppTest):
"""
Smoke test for pages in Studio that are visible when logged out.
"""
def setUp(self):
super(LoggedOutTest, self).setUp()
self.pages = [LoginPage(self.browser), HowitworksPage(self.browser), SignupPage(self.browser)]
def test_page_existence(self):
"""
Make sure that all the pages are accessible.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
for page in self.pages:
page.visit()
class LoggedInPagesTest(WebAppTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and do not have a course yet.
"""
def setUp(self):
super(LoggedInPagesTest, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.dashboard_page = DashboardPage(self.browser)
def test_dashboard_no_courses(self):
"""
Make sure that you can get to the dashboard page without a course.
"""
self.auth_page.visit()
self.dashboard_page.visit()
class CoursePagesTest(UniqueCourseTest):
"""
Tests that verify the pages in Studio that you can get to when logged
in and have a course.
"""
COURSE_ID_SEPARATOR = "."
def setUp(self):
"""
Install a course with no content using a fixture.
"""
super(UniqueCourseTest, self).setUp()
CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
).install()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.pages = [
clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run'])
for clz in [
AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage,
PagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage,
AdvancedSettingsPage, GradingPage, TextbooksPage
]
]
def test_page_existence(self):
"""
Make sure that all these pages are accessible once you have a course.
Rather than fire up the browser just to check each url,
do them all sequentially in this testcase.
"""
# Log in
self.auth_page.visit()
# Verify that each page is available
for page in self.pages:
page.visit()
class DiscussionPreviewTest(UniqueCourseTest):
"""
Tests that Inline Discussions are rendered with a custom preview in Studio
"""
def setUp(self):
super(DiscussionPreviewTest, self).setUp()
CourseFixture(**self.course_info).add_children(
XBlockFixtureDesc("chapter", "Test Section").add_children(
XBlockFixtureDesc("sequential", "Test Subsection").add_children(
XBlockFixtureDesc("vertical", "Test Unit").add_children(
XBlockFixtureDesc(
"discussion",
"Test Discussion",
)
)
)
)
).install()
AutoAuthPage(self.browser, staff=True).visit()
cop = CourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
cop.visit()
self.unit = cop.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit')
self.unit.go_to()
def test_is_preview(self):
"""
Ensure that the preview version of the discussion is rendered.
"""
self.assertTrue(self.unit.q(css=".discussion-preview").present)
self.assertFalse(self.unit.q(css=".discussion-show").present)

Some files were not shown because too many files have changed in this diff Show More