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:
1
AUTHORS
1
AUTHORS
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
351
CONTRIBUTING.rst
351
CONTRIBUTING.rst
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
-----------------
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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', [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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…")})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
define(["backbone", "js/models/course_relative"], function(Backbone, CourseRelativeModel) {
|
||||
var CourseRelativeCollection = Backbone.Collection.extend({
|
||||
model: CourseRelativeModel
|
||||
});
|
||||
return CourseRelativeCollection;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
215
cms/static/js/spec/views/container_spec.js
Normal file
215
cms/static/js/spec/views/container_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
20
cms/static/js/spec_helpers/view_helpers.js
Normal file
20
cms/static/js/spec_helpers/view_helpers.js
Normal 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
|
||||
};
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
115
cms/static/js/views/container.js
Normal file
115
cms/static/js/views/container.js
Normal 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…')
|
||||
});
|
||||
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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +280,8 @@
|
||||
// ====================
|
||||
|
||||
// CASE: user not signed in
|
||||
.not-signedin {
|
||||
.not-signedin,
|
||||
.view-util {
|
||||
|
||||
.wrapper-header {
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
42
common/djangoapps/track/shim.py
Normal file
42
common/djangoapps/track/shim.py
Normal 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
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
121
common/djangoapps/track/tests/test_shim.py
Normal file
121
common/djangoapps/track/tests/test_shim.py
Normal 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]
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
###
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
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);
|
||||
}
|
||||
|
||||
363
common/lib/xmodule/xmodule/lti_2_util.py
Normal file
363
common/lib/xmodule/xmodule/lti_2_util.py
Normal 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', "")
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
26
common/lib/xmodule/xmodule/templates/html/grade_me.yaml
Normal file
26
common/lib/xmodule/xmodule/templates/html/grade_me.yaml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
372
common/lib/xmodule/xmodule/tests/test_lti20_unit.py
Normal file
372
common/lib/xmodule/xmodule/tests/test_lti20_unit.py
Normal 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 <script>alert(3)</script>"), # 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])]
|
||||
|
||||
@@ -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 you’ve 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
|
||||
)
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
312
common/static/js/vendor/jquery.simulate.js
vendored
Normal file
312
common/static/js/vendor/jquery.simulate.js
vendored
Normal 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 );
|
||||
@@ -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`.
|
||||
|
||||
84
common/test/acceptance/pages/lms/staff_view.py
Normal file
84
common/test/acceptance/pages/lms/staff_view.py
Normal 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
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
151
common/test/acceptance/tests/test_staff_view.py
Normal file
151
common/test/acceptance/tests/test_staff_view.py
Normal 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)
|
||||
@@ -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):
|
||||
"""
|
||||
163
common/test/acceptance/tests/test_studio_container.py
Normal file
163
common/test/acceptance/tests/test_studio_container.py
Normal 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)
|
||||
148
common/test/acceptance/tests/test_studio_general.py
Normal file
148
common/test/acceptance/tests/test_studio_general.py
Normal 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
Reference in New Issue
Block a user