Merge pull request #3673 from cpennington/opaque-keys-merge-master
Merge master into opaque-keys
This commit is contained in:
4
AUTHORS
4
AUTHORS
@@ -142,3 +142,7 @@ 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>
|
||||
David Bodor <david.gabor.bodor@gmail.com>
|
||||
Sébastien Hinderer <Sebastien.Hinderer@inria.fr>
|
||||
Kristin Stephens <ksteph@cs.berkeley.edu>
|
||||
|
||||
@@ -5,8 +5,14 @@ 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.
|
||||
|
||||
LMS: Switch default instructor dashboard to the new (formerly "beta")
|
||||
instructor dashboard. Puts the old (now "legacy") dash behind a feature flag.
|
||||
LMS-1296
|
||||
|
||||
Blades: Handle situation if no response were sent from XQueue to LMS in Matlab
|
||||
problem after Run Code button press. BLD-994.
|
||||
|
||||
|
||||
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
|
||||
-----------------
|
||||
|
||||
@@ -53,7 +53,7 @@ Feature: CMS Transcripts
|
||||
# first part of url will be substituted by mock_youtube_server address
|
||||
# for t__eq_exist id server will respond with transcripts
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
# t__eq_exist subs locally not presented at this moment
|
||||
And I see button "import"
|
||||
|
||||
@@ -72,18 +72,18 @@ Feature: CMS Transcripts
|
||||
And I remove "t_not_exist" transcripts id from store
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "Transcript (primary)"
|
||||
And I see value "" in the field "Default Timed Transcript"
|
||||
|
||||
# Import: w/o local but with server subs
|
||||
And I remove "t__eq_exist" transcripts id from store
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I click transcript button "import"
|
||||
Then I see status message "found"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see button "download_to_edit"
|
||||
And I see value "t__eq_exist" in the field "Transcript (primary)"
|
||||
And I see value "t__eq_exist" in the field "Default Timed Transcript"
|
||||
|
||||
#4
|
||||
Scenario: Youtube id only: check "Found" state
|
||||
@@ -92,7 +92,7 @@ Feature: CMS Transcripts
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "t_not_exist" in the field "Transcript (primary)"
|
||||
And I see value "t_not_exist" in the field "Default Timed Transcript"
|
||||
|
||||
#5
|
||||
Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are equal
|
||||
@@ -102,7 +102,7 @@ Feature: CMS Transcripts
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
And I see status message "found"
|
||||
And I see value "t__eq_exist" in the field "Transcript (primary)"
|
||||
And I see value "t__eq_exist" in the field "Default Timed Transcript"
|
||||
|
||||
#6
|
||||
Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are not equal
|
||||
@@ -114,7 +114,7 @@ Feature: CMS Transcripts
|
||||
And I see button "replace"
|
||||
And I click transcript button "replace"
|
||||
And I see status message "found"
|
||||
And I see value "t_neq_exist" in the field "Transcript (primary)"
|
||||
And I see value "t_neq_exist" in the field "Default Timed Transcript"
|
||||
|
||||
#7
|
||||
Scenario: html5 source only: check "Not Found" state
|
||||
@@ -123,7 +123,7 @@ Feature: CMS Transcripts
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "Transcript (primary)"
|
||||
And I see value "" in the field "Default Timed Transcript"
|
||||
|
||||
#8
|
||||
Scenario: html5 source only: check "Found" state
|
||||
@@ -132,7 +132,7 @@ Feature: CMS Transcripts
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "t_not_exist" in the field "Transcript (primary)"
|
||||
And I see value "t_not_exist" in the field "Default Timed Transcript"
|
||||
|
||||
#9
|
||||
Scenario: User sets youtube_id w/o server but with local subs and one html5 link w/o subs
|
||||
@@ -144,7 +144,7 @@ Feature: CMS Transcripts
|
||||
|
||||
And I enter a "test_video_name.mp4" source to field number 2
|
||||
Then I see status message "found"
|
||||
And I see value "t_not_exist" in the field "Transcript (primary)"
|
||||
And I see value "t_not_exist" in the field "Default Timed Transcript"
|
||||
|
||||
# Disabled 1/29/14 due to flakiness observed in master
|
||||
#10
|
||||
@@ -153,14 +153,14 @@ Feature: CMS Transcripts
|
||||
# And I edit the component
|
||||
#
|
||||
# And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
# Then I see status message "not found"
|
||||
# Then I see status message "not found on edx"
|
||||
# And I see button "import"
|
||||
# And I click transcript button "import"
|
||||
# Then I see status message "found"
|
||||
#
|
||||
# And I enter a "t_not_exist.mp4" source to field number 2
|
||||
# Then I see status message "found"
|
||||
# And I see value "t__eq_exist" in the field "Transcript (primary)"
|
||||
# And I see value "t__eq_exist" in the field "Default Timed Transcript"
|
||||
|
||||
#11
|
||||
Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts
|
||||
@@ -168,17 +168,17 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 3
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
@@ -205,7 +205,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I click transcript button "import"
|
||||
Then I see status message "found"
|
||||
@@ -247,17 +247,17 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_neq_exist.webm" source to field number 3
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
@@ -267,17 +267,17 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_neq_exist.mp4" source to field number 2
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 3
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
|
||||
@@ -287,7 +287,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I click transcript button "import"
|
||||
Then I see status message "found"
|
||||
@@ -309,7 +309,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I enter a "http://youtu.be/t__eq_exist" source to field number 1
|
||||
Then I see status message "not found"
|
||||
Then I see status message "not found on edx"
|
||||
And I see button "import"
|
||||
And I click transcript button "import"
|
||||
Then I see status message "found"
|
||||
@@ -338,7 +338,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see value "t__eq_exist" in the field "Transcript (primary)"
|
||||
And I see value "t__eq_exist" in the field "Default Timed Transcript"
|
||||
|
||||
And I enter a "http://youtu.be/t_not_exist" source to field number 2
|
||||
Then I see status message "found"
|
||||
@@ -359,7 +359,7 @@ Feature: CMS Transcripts
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I upload the transcripts file "uk_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see value "uk_transcripts" in the field "Transcript (primary)"
|
||||
And I see value "uk_transcripts" in the field "Default Timed Transcript"
|
||||
|
||||
And I enter a "t_not_exist.webm" source to field number 2
|
||||
Then I see status message "replace"
|
||||
@@ -367,7 +367,7 @@ Feature: CMS Transcripts
|
||||
And I see choose button "uk_transcripts.mp4" number 1
|
||||
And I see choose button "t_not_exist.webm" number 2
|
||||
And I click transcript button "choose" number 2
|
||||
And I see value "uk_transcripts|t_not_exist" in the field "Transcript (primary)"
|
||||
And I see value "uk_transcripts|t_not_exist" in the field "Default Timed Transcript"
|
||||
|
||||
# Flaky test fails occasionally in master. https://edx-wiki.atlassian.net/browse/BLD-927
|
||||
#21
|
||||
@@ -379,7 +379,7 @@ Feature: CMS Transcripts
|
||||
# Then I see status message "found"
|
||||
# And I see button "download_to_edit"
|
||||
# And I see button "upload_new_timed_transcripts"
|
||||
# And I see value "t_not_exist" in the field "Transcript (primary)"
|
||||
# And I see value "t_not_exist" in the field "Default Timed Transcript"
|
||||
#
|
||||
# And I save changes
|
||||
# And I edit the component
|
||||
@@ -388,13 +388,13 @@ Feature: CMS Transcripts
|
||||
# Then I see status message "use existing"
|
||||
# And I see button "use_existing"
|
||||
# And I click transcript button "use_existing"
|
||||
# And I see value "video_name_2" in the field "Transcript (primary)"
|
||||
# And I see value "video_name_2" in the field "Default Timed Transcript"
|
||||
#
|
||||
# And I enter a "video_name_3.mp4" source to field number 1
|
||||
# Then I see status message "use existing"
|
||||
# And I see button "use_existing"
|
||||
# And I click transcript button "use_existing"
|
||||
# And I see value "video_name_3" in the field "Transcript (primary)"
|
||||
# And I see value "video_name_3" in the field "Default Timed Transcript"
|
||||
|
||||
#22
|
||||
Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - click on use existing -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> change it to another one HTML5 source w/o transcripts - click on use existing
|
||||
@@ -405,7 +405,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "found"
|
||||
And I see button "download_to_edit"
|
||||
And I see button "upload_new_timed_transcripts"
|
||||
And I see value "t_not_exist" in the field "Transcript (primary)"
|
||||
And I see value "t_not_exist" in the field "Default Timed Transcript"
|
||||
|
||||
And I save changes
|
||||
And I edit the component
|
||||
@@ -414,7 +414,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click transcript button "use_existing"
|
||||
And I see value "video_name_2" in the field "Transcript (primary)"
|
||||
And I see value "video_name_2" in the field "Default Timed Transcript"
|
||||
|
||||
And I enter a "video_name_3.mp4" source to field number 1
|
||||
Then I see status message "use existing"
|
||||
@@ -424,7 +424,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click transcript button "use_existing"
|
||||
And I see value "video_name_4" in the field "Transcript (primary)"
|
||||
And I see value "video_name_4" in the field "Default Timed Transcript"
|
||||
|
||||
#23
|
||||
Scenario: Work with 2 fields: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> add another one HTML5 source w/o transcripts - click on use existing
|
||||
@@ -447,7 +447,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "use existing"
|
||||
And I see button "use_existing"
|
||||
And I click transcript button "use_existing"
|
||||
And I see value "video_name_2|video_name_3" in the field "Transcript (primary)"
|
||||
And I see value "video_name_2|video_name_3" in the field "Default Timed Transcript"
|
||||
|
||||
#24 Uploading subtitles with different file name than file
|
||||
Scenario: File name and name of subs are different
|
||||
@@ -458,7 +458,7 @@ Feature: CMS Transcripts
|
||||
And I see status message "not found"
|
||||
And I upload the transcripts file "uk_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see value "video_name_1" in the field "Transcript (primary)"
|
||||
And I see value "video_name_1" in the field "Default Timed Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
@@ -489,11 +489,11 @@ Feature: CMS Transcripts
|
||||
And I see status message "not found"
|
||||
And I upload the transcripts file "uk_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see value "video_name_1|video_name_2" in the field "Transcript (primary)"
|
||||
And I see value "video_name_1|video_name_2" in the field "Default Timed Transcript"
|
||||
|
||||
And I clear field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_2" in the field "Transcript (primary)"
|
||||
And I see value "video_name_2" in the field "Default Timed Transcript"
|
||||
|
||||
#27
|
||||
Scenario: Upload button for single youtube id
|
||||
@@ -529,7 +529,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I clear field number 1
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_1" in the field "Transcript (primary)"
|
||||
And I see value "video_name_1" in the field "Default Timed Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
@@ -545,14 +545,14 @@ Feature: CMS Transcripts
|
||||
Then I see status message "not found"
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "t_not_exist" to the field "Transcript (primary)"
|
||||
And I set value "t_not_exist" to the field "Default Timed Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_1" in the field "Transcript (primary)"
|
||||
And I see value "video_name_1" in the field "Default Timed Transcript"
|
||||
|
||||
#30
|
||||
Scenario: Check non-ascii (chinise) transcripts
|
||||
@@ -577,7 +577,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "not found"
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "t_not_exist" to the field "Transcript (primary)"
|
||||
And I set value "t_not_exist" to the field "Default Timed Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "found"
|
||||
|
||||
@@ -586,7 +586,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "found"
|
||||
And I see value "video_name_1" in the field "Transcript (primary)"
|
||||
And I see value "video_name_1" in the field "Default Timed Transcript"
|
||||
|
||||
#32
|
||||
Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible w/o saving
|
||||
@@ -599,7 +599,7 @@ Feature: CMS Transcripts
|
||||
Then I see status message "uploaded_successfully"
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "" to the field "Transcript (primary)"
|
||||
And I set value "" to the field "Default Timed Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "not found"
|
||||
|
||||
@@ -608,7 +608,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "Transcript (primary)"
|
||||
And I see value "" in the field "Default Timed Transcript"
|
||||
|
||||
#33
|
||||
Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible with saving
|
||||
@@ -625,7 +625,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "" to the field "Transcript (primary)"
|
||||
And I set value "" to the field "Default Timed Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "not found"
|
||||
|
||||
@@ -634,7 +634,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
Then I see status message "not found"
|
||||
And I see value "" in the field "Transcript (primary)"
|
||||
And I see value "" in the field "Default Timed Transcript"
|
||||
|
||||
#34
|
||||
Scenario: Video with existing subs - Advanced tab - change to another one subs - Basic tab - Found message - Save - see correct subs
|
||||
@@ -653,7 +653,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I set value "t_not_exist" to the field "Transcript (primary)"
|
||||
And I set value "t_not_exist" to the field "Default Timed Transcript"
|
||||
And I open tab "Basic"
|
||||
Then I see status message "found"
|
||||
|
||||
@@ -676,7 +676,7 @@ Feature: CMS Transcripts
|
||||
And I edit the component
|
||||
|
||||
And I open tab "Advanced"
|
||||
And I revert the transcript field "Transcript (primary)"
|
||||
And I revert the transcript field "Default Timed Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does not show the captions
|
||||
@@ -692,7 +692,7 @@ Feature: CMS Transcripts
|
||||
And I see status message "not found"
|
||||
And I upload the transcripts file "uk_transcripts.srt"
|
||||
Then I see status message "uploaded_successfully"
|
||||
And I see value "video_name_1.1.2" in the field "Transcript (primary)"
|
||||
And I see value "video_name_1.1.2" in the field "Default Timed Transcript"
|
||||
|
||||
And I save changes
|
||||
Then when I view the video it does show the captions
|
||||
|
||||
@@ -23,10 +23,11 @@ ERROR_MESSAGES = {
|
||||
|
||||
STATUSES = {
|
||||
'found': u'Timed Transcript Found',
|
||||
'not found on edx': u'No EdX Timed Transcript',
|
||||
'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,13 +40,13 @@ 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'),
|
||||
'upload_new_timed_transcripts': ('.setting-upload', 'Upload New Timed Transcript'),
|
||||
'replace': ('.setting-replace', 'Yes, Replace EdX Timed Transcript with YouTube Timed Transcript'),
|
||||
'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 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'),
|
||||
'use_existing': ('.setting-use-existing', 'Use Current Transcript'),
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +210,8 @@ def check_text_in_the_captions(_step, text):
|
||||
@step('I see value "([^"]*)" in the field "([^"]*)"$')
|
||||
def check_transcripts_field(_step, values, field_name):
|
||||
world.select_editor_tab('Advanced')
|
||||
field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for']
|
||||
tab = world.css_find('#settings-tab').first;
|
||||
field_id = '#' + tab.find_by_xpath('.//label[text()="%s"]' % field_name.strip())[0]['for']
|
||||
values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')]
|
||||
assert any(values_list)
|
||||
world.select_editor_tab('Basic')
|
||||
@@ -227,8 +229,9 @@ def open_tab(_step, tab_name):
|
||||
|
||||
@step('I set value "([^"]*)" to the field "([^"]*)"$')
|
||||
def set_value_transcripts_field(_step, value, field_name):
|
||||
XPATH = '//label[text()="{name}"]'.format(name=field_name)
|
||||
SELECTOR = '#' + world.browser.find_by_xpath(XPATH)[0]['for']
|
||||
tab = world.css_find('#settings-tab').first;
|
||||
XPATH = './/label[text()="{name}"]'.format(name=field_name)
|
||||
SELECTOR = '#' + tab.find_by_xpath(XPATH)[0]['for']
|
||||
element = world.css_find(SELECTOR).first
|
||||
if element['type'] == 'text':
|
||||
SCRIPT = '$("{selector}").val("{value}").change()'.format(
|
||||
|
||||
@@ -72,8 +72,8 @@ Feature: CMS Video Component
|
||||
And Make sure captions are closed
|
||||
And I edit the component
|
||||
And I open tab "Advanced"
|
||||
And I set value "00:00:12" to the field "Start Time"
|
||||
And I set value "00:00:24" to the field "End Time"
|
||||
And I set value "00:00:12" to the field "Video Start Time"
|
||||
And I set value "00:00:24" to the field "Video Stop Time"
|
||||
And I save changes
|
||||
And I click video button "play"
|
||||
Then I see a range on slider
|
||||
@@ -85,8 +85,8 @@ Feature: CMS Video Component
|
||||
# And Make sure captions are closed
|
||||
# And I edit the component
|
||||
# And I open tab "Advanced"
|
||||
# And I set value "00:00:12" to the field "Start Time"
|
||||
# And I set value "00:00:24" to the field "End Time"
|
||||
# And I set value "00:00:12" to the field "Video Start Time"
|
||||
# And I set value "00:00:24" to the field "Video Stop Time"
|
||||
# And I save changes
|
||||
# And I click video button "play"
|
||||
# Then I see a range on slider
|
||||
@@ -103,8 +103,8 @@ Feature: CMS Video Component
|
||||
# And Make sure captions are closed
|
||||
# And I edit the component
|
||||
# And I open tab "Advanced"
|
||||
# And I set value "00:00:12" to the field "Start Time"
|
||||
# And I set value "00:00:24" to the field "End Time"
|
||||
# And I set value "00:00:12" to the field "Video Start Time"
|
||||
# And I set value "00:00:24" to the field "Video Stop Time"
|
||||
# And I save changes
|
||||
# And I click video button "play"
|
||||
# Then I see a range on slider
|
||||
@@ -121,8 +121,8 @@ Feature: CMS Video Component
|
||||
# And Make sure captions are closed
|
||||
# And I edit the component
|
||||
# And I open tab "Advanced"
|
||||
# And I set value "00:00:12" to the field "Start Time"
|
||||
# And I set value "00:00:24" to the field "End Time"
|
||||
# And I set value "00:00:12" to the field "Video Start Time"
|
||||
# And I set value "00:00:24" to the field "Video Stop Time"
|
||||
# And I save changes
|
||||
# And I click video button "play"
|
||||
# Then I see a range on slider
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@shard_3
|
||||
@shard_1
|
||||
Feature: CMS Video Component Editor
|
||||
As a course author, I want to be able to create video components
|
||||
|
||||
@@ -15,7 +15,7 @@ Feature: CMS Video Component Editor
|
||||
Given I have created a Video component
|
||||
And I edit the component
|
||||
And I open tab "Advanced"
|
||||
Then I can modify the display name
|
||||
Then I can modify video display name
|
||||
And my video display name change is persisted on save
|
||||
|
||||
# 3
|
||||
|
||||
@@ -11,6 +11,7 @@ from common import upload_file, attach_file
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
DISPLAY_NAME = "Component Display Name"
|
||||
NATIVE_LANGUAGES = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
|
||||
LANGUAGES = {
|
||||
lang: NATIVE_LANGUAGES.get(lang, display)
|
||||
@@ -76,7 +77,7 @@ def success_upload_file(filename):
|
||||
|
||||
|
||||
def get_translations_container():
|
||||
return world.browser.find_by_xpath('//label[text()="Transcript Translations"]/following-sibling::div')
|
||||
return world.browser.find_by_xpath('//label[text()="Transcript Languages"]/following-sibling::div')
|
||||
|
||||
|
||||
def get_setting_container(lang_code):
|
||||
@@ -114,7 +115,7 @@ def set_show_captions(step, setting):
|
||||
|
||||
world.edit_component()
|
||||
world.select_editor_tab('Advanced')
|
||||
world.browser.select('Transcript Display', setting)
|
||||
world.browser.select('Show Transcript', setting)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@@ -136,25 +137,25 @@ def shows_captions(_step, show_captions):
|
||||
def correct_video_settings(_step):
|
||||
expected_entries = [
|
||||
# basic
|
||||
['Display Name', 'Video', False],
|
||||
['Video URL', 'http://youtu.be/OEoXaMPEzfM, , ', False],
|
||||
[DISPLAY_NAME, 'Video', False],
|
||||
['Default Video URL', 'http://youtu.be/OEoXaMPEzfM, , ', False],
|
||||
|
||||
# advanced
|
||||
['Display Name', 'Video', False],
|
||||
['Download Transcript', '', False],
|
||||
['End Time', '00:00:00', False],
|
||||
['Start Time', '00:00:00', False],
|
||||
['Transcript (primary)', '', False],
|
||||
['Transcript Display', 'True', False],
|
||||
['Transcript Download Allowed', 'False', False],
|
||||
['Transcript Translations', '', False],
|
||||
[DISPLAY_NAME, 'Video', False],
|
||||
['Default Timed Transcript', '', False],
|
||||
['Download Transcript Allowed', 'False', False],
|
||||
['Downloadable Transcript URL', '', False],
|
||||
['Show Transcript', 'True', False],
|
||||
['Transcript Languages', '', False],
|
||||
['Upload Handout', '', False],
|
||||
['Video Download Allowed', 'False', False],
|
||||
['Video Sources', '', False],
|
||||
['Youtube ID', 'OEoXaMPEzfM', False],
|
||||
['Youtube ID for .75x speed', '', False],
|
||||
['Youtube ID for 1.25x speed', '', False],
|
||||
['Youtube ID for 1.5x speed', '', False]
|
||||
['Video File URLs', '', False],
|
||||
['Video Start Time', '00:00:00', False],
|
||||
['Video Stop Time', '00:00:00', False],
|
||||
['YouTube ID', 'OEoXaMPEzfM', False],
|
||||
['YouTube ID for .75x speed', '', False],
|
||||
['YouTube ID for 1.25x speed', '', False],
|
||||
['YouTube ID for 1.5x speed', '', False]
|
||||
]
|
||||
world.verify_all_setting_entries(expected_entries)
|
||||
|
||||
@@ -167,11 +168,18 @@ def video_name_persisted(step):
|
||||
world.edit_component()
|
||||
|
||||
world.verify_setting_entry(
|
||||
world.get_setting_entry('Display Name'),
|
||||
'Display Name', '3.4', True
|
||||
world.get_setting_entry(DISPLAY_NAME),
|
||||
DISPLAY_NAME, '3.4', True
|
||||
)
|
||||
|
||||
|
||||
@step('I can modify video display name')
|
||||
def i_can_modify_video_display_name(_step):
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, '3.4')
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True)
|
||||
|
||||
|
||||
@step('I upload transcript file(?:s)?:$')
|
||||
def upload_transcript(step):
|
||||
input_hidden = '.metadata-video-translations .input'
|
||||
|
||||
@@ -23,8 +23,21 @@ class TestImport(ModuleStoreTestCase):
|
||||
Unit tests for importing a course from command line
|
||||
"""
|
||||
|
||||
COURSE_KEY = SlashSeparatedCourseKey(u'edX', u'test_import_course', u'2013_Spring')
|
||||
BASE_COURSE_KEY = SlashSeparatedCourseKey(u'edX', u'test_import_course', u'2013_Spring')
|
||||
DIFF_KEY = SlashSeparatedCourseKey(u'edX', u'test_import_course', u'2014_Spring')
|
||||
TRUNCATED_KEY = SlashSeparatedCourseKey(u'edX', u'test_import', u'2014_Spring')
|
||||
|
||||
def create_course_xml(self, content_dir, course_id):
|
||||
directory = tempfile.mkdtemp(dir=content_dir)
|
||||
os.makedirs(os.path.join(directory, "course"))
|
||||
with open(os.path.join(directory, "course.xml"), "w+") as f:
|
||||
f.write('<course url_name="{0.run}" org="{0.org}" '
|
||||
'course="{0.course}"/>'.format(course_id))
|
||||
|
||||
with open(os.path.join(directory, "course", "{0.run}.xml".format(course_id)), "w+") as f:
|
||||
f.write('<course></course>')
|
||||
|
||||
return directory
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
@@ -35,32 +48,22 @@ class TestImport(ModuleStoreTestCase):
|
||||
self.addCleanup(shutil.rmtree, self.content_dir)
|
||||
|
||||
# Create good course xml
|
||||
self.good_dir = tempfile.mkdtemp(dir=self.content_dir)
|
||||
os.makedirs(os.path.join(self.good_dir, "course"))
|
||||
with open(os.path.join(self.good_dir, "course.xml"), "w+") as f:
|
||||
f.write('<course url_name="{0.run}" org="{0.org}" '
|
||||
'course="{0.course}"/>'.format(self.COURSE_KEY))
|
||||
|
||||
with open(os.path.join(self.good_dir, "course", "{0.run}.xml".format(self.COURSE_KEY)), "w+") as f:
|
||||
f.write('<course></course>')
|
||||
self.good_dir = self.create_course_xml(self.content_dir, self.BASE_COURSE_KEY)
|
||||
|
||||
# Create run changed course xml
|
||||
self.dupe_dir = tempfile.mkdtemp(dir=self.content_dir)
|
||||
os.makedirs(os.path.join(self.dupe_dir, "course"))
|
||||
with open(os.path.join(self.dupe_dir, "course.xml"), "w+") as f:
|
||||
f.write('<course url_name="{0.run}" org="{0.org}" '
|
||||
'course="{0.course}"/>'.format(self.DIFF_KEY))
|
||||
self.dupe_dir = self.create_course_xml(self.content_dir, self.DIFF_KEY)
|
||||
|
||||
with open(os.path.join(self.dupe_dir, "course", "{0.run}.xml".format(self.DIFF_KEY)), "w+") as f:
|
||||
f.write('<course></course>')
|
||||
# Create course XML where TRUNCATED_COURSE.org == BASE_COURSE_ID.org
|
||||
# and BASE_COURSE_ID.startswith(TRUNCATED_COURSE.course)
|
||||
self.course_dir = self.create_course_xml(self.content_dir, self.TRUNCATED_KEY)
|
||||
|
||||
def test_forum_seed(self):
|
||||
"""
|
||||
Tests that forum roles were created with import.
|
||||
"""
|
||||
self.assertFalse(are_permissions_roles_seeded(self.COURSE_KEY))
|
||||
self.assertFalse(are_permissions_roles_seeded(self.BASE_COURSE_KEY))
|
||||
call_command('import', self.content_dir, self.good_dir)
|
||||
self.assertTrue(are_permissions_roles_seeded(self.COURSE_KEY))
|
||||
self.assertTrue(are_permissions_roles_seeded(self.BASE_COURSE_KEY))
|
||||
|
||||
def test_duplicate_with_url(self):
|
||||
"""
|
||||
@@ -71,9 +74,24 @@ class TestImport(ModuleStoreTestCase):
|
||||
# Load up base course and verify it is available
|
||||
call_command('import', self.content_dir, self.good_dir)
|
||||
store = modulestore()
|
||||
self.assertIsNotNone(store.get_course(self.COURSE_KEY))
|
||||
self.assertIsNotNone(store.get_course(self.BASE_COURSE_KEY))
|
||||
|
||||
# Now load up duped course and verify it doesn't load
|
||||
call_command('import', self.content_dir, self.dupe_dir)
|
||||
self.assertIsNone(store.get_course(self.DIFF_KEY))
|
||||
self.assertTrue(are_permissions_roles_seeded(self.COURSE_KEY))
|
||||
|
||||
def test_truncated_course_with_url(self):
|
||||
"""
|
||||
Check to make sure an import only blocks true duplicates: new
|
||||
courses with similar but not unique org/course combinations aren't
|
||||
blocked if the original course's course starts with the new course's
|
||||
org/course combinations (i.e. EDx/0.00x/Spring -> EDx/0.00/Spring)
|
||||
"""
|
||||
# Load up base course and verify it is available
|
||||
call_command('import', self.content_dir, self.good_dir)
|
||||
store = modulestore()
|
||||
self.assertIsNotNone(store.get_course(self.BASE_COURSE_KEY))
|
||||
|
||||
# Now load up the course with a similar course_id and verify it loads
|
||||
call_command('import', self.content_dir, self.course_dir)
|
||||
self.assertIsNotNone(store.get_course(self.TRUNCATED_KEY))
|
||||
|
||||
@@ -65,17 +65,39 @@ class ContentStoreImportTest(ModuleStoreTestCase):
|
||||
|
||||
def load_test_import_course(self):
|
||||
'''
|
||||
Load the standard course used to test imports (for do_import_static=False behavior).
|
||||
Load the standard course used to test imports
|
||||
(for do_import_static=False behavior).
|
||||
'''
|
||||
content_store = contentstore()
|
||||
module_store = modulestore('direct')
|
||||
import_from_xml(module_store, 'common/test/data/', ['test_import_course'], static_content_store=content_store, do_import_static=False, verbose=True)
|
||||
import_from_xml(
|
||||
module_store,
|
||||
'common/test/data/',
|
||||
['test_import_course'],
|
||||
static_content_store=content_store,
|
||||
do_import_static=False,
|
||||
verbose=True,
|
||||
)
|
||||
course_id = SlashSeparatedCourseKey('edX', 'test_import_course', '2012_Fall')
|
||||
course = module_store.get_course(course_id)
|
||||
self.assertIsNotNone(course)
|
||||
|
||||
return module_store, content_store, course
|
||||
|
||||
def test_import_course_into_similar_namespace(self):
|
||||
# Checks to make sure that a course with an org/course like
|
||||
# edx/course can be imported into a namespace with an org/course
|
||||
# like edx/course_name
|
||||
module_store, __, course = self.load_test_import_course()
|
||||
__, course_items = import_from_xml(
|
||||
module_store,
|
||||
'common/test/data',
|
||||
['test_import_course_2'],
|
||||
target_course_id=course.id,
|
||||
verbose=True,
|
||||
)
|
||||
self.assertEqual(len(course_items), 1)
|
||||
|
||||
def test_unicode_chars_in_course_name_import(self):
|
||||
"""
|
||||
# Test that importing course with unicode 'id' and 'display name' doesn't give UnicodeEncodeError
|
||||
|
||||
@@ -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
|
||||
@@ -178,6 +179,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
if 'application/json' in accept_header:
|
||||
store = get_modulestore(usage_key)
|
||||
component = store.get_item(usage_key)
|
||||
is_read_only = _xblock_is_read_only(component)
|
||||
|
||||
# wrap the generated fragment in the xmodule_editor div so that the javascript
|
||||
# can bind to it correctly
|
||||
@@ -197,12 +199,18 @@ 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,
|
||||
'locator': usage_key,
|
||||
'reordering_enabled': True,
|
||||
})
|
||||
return JsonResponse({
|
||||
'html': html,
|
||||
@@ -210,8 +218,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 +225,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 +235,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,6 +255,17 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
return HttpResponse(status=406)
|
||||
|
||||
|
||||
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_key, data=None, children=None, metadata=None, nullout=None,
|
||||
grader_type=None, publish=None):
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,8 @@ from django.conf import settings
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
|
||||
from external_auth.views import ssl_login_shortcut, ssl_get_cert_from_request
|
||||
from external_auth.views import (ssl_login_shortcut, ssl_get_cert_from_request,
|
||||
redirect_with_get)
|
||||
from microsite_configuration import microsite
|
||||
|
||||
__all__ = ['signup', 'login_page', 'howitworks']
|
||||
@@ -26,7 +27,7 @@ def signup(request):
|
||||
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'):
|
||||
# Redirect to course to login to process their certificate if SSL is enabled
|
||||
# and registration is disabled.
|
||||
return redirect(reverse('login'))
|
||||
return redirect_with_get('login', request.GET, False)
|
||||
|
||||
return render_to_response('register.html', {'csrf': csrf_token})
|
||||
|
||||
@@ -43,7 +44,11 @@ def login_page(request):
|
||||
# SSL login doesn't require a login view, so redirect
|
||||
# to course now that the user is authenticated via
|
||||
# the decorator.
|
||||
return redirect('/course/')
|
||||
next_url = request.GET.get('next')
|
||||
if next_url:
|
||||
return redirect(next_url)
|
||||
else:
|
||||
return redirect('/course/')
|
||||
if settings.FEATURES.get('AUTH_USE_CAS'):
|
||||
# If CAS is enabled, redirect auth handling to there
|
||||
return redirect(reverse('cas-login'))
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
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
|
||||
@@ -51,7 +53,6 @@ class ContainerViewTestCase(CourseTestCase):
|
||||
parent_location=published_xblock_with_child.location,
|
||||
category="html", display_name="Child HTML"
|
||||
)
|
||||
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
|
||||
expected_breadcrumbs = (
|
||||
r'<a href="/unit/{unit_location}"\s*'
|
||||
r'class="navigation-link navigation-parent">Unit</a>\s*'
|
||||
@@ -67,6 +68,11 @@ class ContainerViewTestCase(CourseTestCase):
|
||||
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 +109,36 @@ 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
|
||||
"""
|
||||
publish_state = compute_publish_state(xblock)
|
||||
preview_url = '/xblock/{}/container_preview'.format(xblock.location)
|
||||
|
||||
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,7 +24,7 @@ class TabsPageTests(CourseTestCase):
|
||||
self.url = reverse_course_url('tabs_handler', self.course.id)
|
||||
|
||||
# add a static tab to the course, for code coverage
|
||||
ItemFactory.create(
|
||||
self.test_tab = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
category="static_tab",
|
||||
display_name="Static_1"
|
||||
@@ -173,6 +174,24 @@ 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
|
||||
"""
|
||||
preview_url = '/xblock/{}/student_view'.format(self.test_tab.location)
|
||||
|
||||
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'
|
||||
@@ -319,7 +318,7 @@ PIPELINE_CSS = {
|
||||
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
|
||||
'css/vendor/jquery.qtip.min.css',
|
||||
'js/vendor/markitup/skins/simple/style.css',
|
||||
'js/vendor/markitup/sets/wiki/style.css'
|
||||
'js/vendor/markitup/sets/wiki/style.css',
|
||||
],
|
||||
'output_filename': 'css/cms-style-vendor.css',
|
||||
},
|
||||
@@ -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
|
||||
|
||||
@@ -59,7 +59,8 @@ DEBUG_TOOLBAR_PANELS = (
|
||||
)
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
'INTERCEPT_REDIRECTS': False
|
||||
'INTERCEPT_REDIRECTS': False,
|
||||
'SHOW_TOOLBAR_CALLBACK': lambda _: True,
|
||||
}
|
||||
|
||||
# To see stacktraces for MongoDB queries, set this to True.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -245,7 +245,6 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI",
|
||||
expect(pagingHeader.$('.next-page-link')).toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
|
||||
it('should be disabled on an empty page', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
pagingView.setPage(0);
|
||||
@@ -301,6 +300,31 @@ define([ "jquery", "js/spec_helpers/create_sinon", "URI",
|
||||
});
|
||||
});
|
||||
|
||||
describe("Page metadata section", function() {
|
||||
it('shows the correct metadata for the current page', function () {
|
||||
var requests = create_sinon.requests(this),
|
||||
message;
|
||||
pagingView.setPage(0);
|
||||
respondWithMockAssets(requests);
|
||||
message = pagingHeader.$('.meta').html().trim();
|
||||
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
|
||||
' out of <span class="count-total">4 total</span>, ' +
|
||||
'sorted by <span class="sort-order">Date</span> descending</p>');
|
||||
});
|
||||
|
||||
it('shows the correct metadata when sorted ascending', function () {
|
||||
var requests = create_sinon.requests(this),
|
||||
message;
|
||||
pagingView.setPage(0);
|
||||
pagingView.toggleSortOrder('name-col');
|
||||
respondWithMockAssets(requests);
|
||||
message = pagingHeader.$('.meta').html().trim();
|
||||
expect(message).toBe('<p>Showing <span class="count-current-shown">1-3</span>' +
|
||||
' out of <span class="count-total">4 total</span>, ' +
|
||||
'sorted by <span class="sort-order">Name</span> ascending</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Asset count label", function () {
|
||||
it('should show correct count on first page', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -87,12 +87,6 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
|
||||
return sortInfo.displayName;
|
||||
},
|
||||
|
||||
sortDirectionName: function() {
|
||||
var collection = this.collection,
|
||||
ascending = collection.sortDirection === 'asc';
|
||||
return ascending ? gettext("ascending") : gettext("descending");
|
||||
},
|
||||
|
||||
setInitialSortColumn: function(sortColumn) {
|
||||
var collection = this.collection,
|
||||
sortInfo = this.sortableColumns[sortColumn];
|
||||
|
||||
@@ -31,27 +31,48 @@ define(["underscore", "gettext", "js/views/baseview"], function(_, gettext, Base
|
||||
},
|
||||
|
||||
messageHtml: function() {
|
||||
var message;
|
||||
if (this.view.collection.sortDirection === 'asc') {
|
||||
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added ascending"
|
||||
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s ascending');
|
||||
} else {
|
||||
// Translators: sample result: "Showing 0-9 out of 25 total, sorted by Date Added descending"
|
||||
message = gettext('Showing %(current_item_range)s out of %(total_items_count)s, sorted by %(sort_name)s descending');
|
||||
}
|
||||
return '<p>' + interpolate(message, {
|
||||
current_item_range: this.currentItemRangeLabel(),
|
||||
total_items_count: this.totalItemsCountLabel(),
|
||||
sort_name: this.sortNameLabel()
|
||||
}, true) + "</p>";
|
||||
},
|
||||
|
||||
currentItemRangeLabel: function() {
|
||||
var view = this.view,
|
||||
collection = view.collection,
|
||||
start = collection.start,
|
||||
count = collection.size(),
|
||||
sortName = view.sortDisplayName(),
|
||||
sortDirectionName = view.sortDirectionName(),
|
||||
end = start + count,
|
||||
total = collection.totalCount,
|
||||
fmts = gettext('Showing %(current_span)s%(start)s-%(end)s%(end_span)s out of %(total_span)s%(total)s total%(end_span)s, sorted by %(order_span)s%(sort_order)s%(end_span)s %(sort_direction)s');
|
||||
|
||||
return '<p>' + interpolate(fmts, {
|
||||
end = start + count;
|
||||
return interpolate('<span class="count-current-shown">%(start)s-%(end)s</span>', {
|
||||
start: Math.min(start + 1, end),
|
||||
end: end,
|
||||
total: total,
|
||||
sort_order: sortName,
|
||||
sort_direction: sortDirectionName,
|
||||
current_span: '<span class="count-current-shown">',
|
||||
total_span: '<span class="count-total">',
|
||||
order_span: '<span class="sort-order">',
|
||||
end_span: '</span>'
|
||||
}, true) + "</p>";
|
||||
end: end
|
||||
}, true);
|
||||
},
|
||||
|
||||
totalItemsCountLabel: function() {
|
||||
var totalItemsLabel;
|
||||
// Translators: turns into "25 total" to be used in other sentences, e.g. "Showing 0-9 out of 25 total".
|
||||
totalItemsLabel = interpolate(gettext('%(total_items)s total'), {
|
||||
total_items: this.view.collection.totalCount
|
||||
}, true);
|
||||
return interpolate('<span class="count-total">%(total_items_label)s</span>', {
|
||||
total_items_label: totalItemsLabel
|
||||
}, true);
|
||||
},
|
||||
|
||||
sortNameLabel: function() {
|
||||
return interpolate('<span class="sort-order">%(sort_name)s</span>', {
|
||||
sort_name: this.view.sortDisplayName()
|
||||
}, true);
|
||||
},
|
||||
|
||||
nextPage: function() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -179,10 +179,6 @@
|
||||
height: 365px;
|
||||
}
|
||||
|
||||
&.modal-type-problem .CodeMirror {
|
||||
height: 435px;
|
||||
}
|
||||
|
||||
.wrapper-comp-settings {
|
||||
|
||||
.list-input {
|
||||
|
||||
@@ -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,16 +1,16 @@
|
||||
<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">
|
||||
<%= 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") %>">
|
||||
<%= gettext("Upload New Timed Transcript") %>
|
||||
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Transcript") %>" data-tooltip="<%= gettext("Upload New Transcript") %>">
|
||||
<%= gettext("Upload New 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,16 +1,16 @@
|
||||
<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">
|
||||
<%= 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 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,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,22 +18,22 @@
|
||||
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 Transcript") %>"
|
||||
data-tooltip="<%= gettext("Use Current Transcript") %>"
|
||||
>
|
||||
<span>
|
||||
<%= gettext("Use Existing Timed Transcript") %>
|
||||
<%= gettext("Use Current Transcript") %>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="action setting-upload"
|
||||
type="button"
|
||||
name="setting-upload"
|
||||
value="<%= gettext("Upload New Timed Transcript") %>"
|
||||
data-tooltip="<%= gettext("Upload New Timed Transcript") %>"
|
||||
value="<%= gettext("Upload New Transcript") %>"
|
||||
data-tooltip="<%= gettext("Upload New Transcript") %>"
|
||||
>
|
||||
<span>
|
||||
<%= gettext("Upload New Timed Transcript") %>
|
||||
<%= gettext("Upload New Transcript") %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -72,7 +72,7 @@ urlpatterns += patterns(
|
||||
r'^course_info_update/(?P<course_key_string>[^/]+)/(?P<provided_id>\d+)?$',
|
||||
'course_info_update_handler'
|
||||
),
|
||||
url(r'^course/(?P<course_key_string>[^/]+)?$', 'course_handler'),
|
||||
url(r'^course/(?P<course_key_string>[^/]+)?$', 'course_handler', name='course_handler'),
|
||||
url(r'^subsection/(?P<usage_key_string>[^/]+)$', 'subsection_handler'),
|
||||
url(r'^unit/(?P<usage_key_string>[^/]+)$', 'unit_handler'),
|
||||
url(r'^container/(?P<usage_key_string>[^/]+)$', 'container_handler'),
|
||||
@@ -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.
|
||||
|
||||
@@ -16,22 +16,33 @@ from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from mock import Mock
|
||||
|
||||
import external_auth.views
|
||||
from edxmako.middleware import MakoMiddleware
|
||||
from external_auth.models import ExternalAuthMap
|
||||
import external_auth.views
|
||||
from student.tests.factories import UserFactory
|
||||
from opaque_keys import InvalidKeyError
|
||||
from student.models import CourseEnrollment
|
||||
from student.roles import CourseStaffRole
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import loc_mapper
|
||||
from xmodule.modulestore.exceptions import InsufficientSpecificationError
|
||||
from xmodule.modulestore.tests.django_utils import (ModuleStoreTestCase,
|
||||
mixed_store_config)
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
FEATURES_WITH_SSL_AUTH = settings.FEATURES.copy()
|
||||
FEATURES_WITH_SSL_AUTH['AUTH_USE_CERTIFICATES'] = True
|
||||
FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP = FEATURES_WITH_SSL_AUTH.copy()
|
||||
FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP['AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'] = True
|
||||
FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE = FEATURES_WITH_SSL_AUTH_IMMEDIATE_SIGNUP.copy()
|
||||
FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] = True
|
||||
FEATURES_WITHOUT_SSL_AUTH = settings.FEATURES.copy()
|
||||
FEATURES_WITHOUT_SSL_AUTH['AUTH_USE_CERTIFICATES'] = False
|
||||
|
||||
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {})
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH)
|
||||
class SSLClientTest(TestCase):
|
||||
class SSLClientTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests SSL Authentication code sections of external_auth
|
||||
"""
|
||||
@@ -168,7 +179,8 @@ class SSLClientTest(TestCase):
|
||||
response = self.client.get(
|
||||
reverse('dashboard'), follow=True,
|
||||
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
|
||||
self.assertIn(reverse('dashboard'), response['location'])
|
||||
self.assertEquals(('http://testserver/dashboard', 302),
|
||||
response.redirect_chain[-1])
|
||||
self.assertIn(SESSION_KEY, self.client.session)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@@ -181,7 +193,8 @@ class SSLClientTest(TestCase):
|
||||
response = self.client.get(
|
||||
reverse('register_user'), follow=True,
|
||||
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
|
||||
self.assertIn(reverse('dashboard'), response['location'])
|
||||
self.assertEquals(('http://testserver/dashboard', 302),
|
||||
response.redirect_chain[-1])
|
||||
self.assertIn(SESSION_KEY, self.client.session)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
|
||||
@@ -228,7 +241,8 @@ class SSLClientTest(TestCase):
|
||||
response = self.client.get(
|
||||
reverse('signin_user'), follow=True,
|
||||
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
|
||||
self.assertIn(reverse('dashboard'), response['location'])
|
||||
self.assertEquals(('http://testserver/dashboard', 302),
|
||||
response.redirect_chain[-1])
|
||||
self.assertIn(SESSION_KEY, self.client.session)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@@ -318,3 +332,89 @@ class SSLClientTest(TestCase):
|
||||
self.assertEqual(1, len(ExternalAuthMap.objects.all()))
|
||||
|
||||
self.assertTrue(self.mock.called)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE,
|
||||
MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
def test_ssl_lms_redirection(self):
|
||||
"""
|
||||
Auto signup auth user and ensure they return to the original
|
||||
url they visited after being logged in.
|
||||
"""
|
||||
course = CourseFactory.create(
|
||||
org='MITx',
|
||||
number='999',
|
||||
display_name='Robot Super Course'
|
||||
)
|
||||
|
||||
external_auth.views.ssl_login(self._create_ssl_request('/'))
|
||||
user = User.objects.get(email=self.USER_EMAIL)
|
||||
CourseEnrollment.enroll(user, course.id)
|
||||
course_private_url = '/courses/MITx/999/Robot_Super_Course/courseware'
|
||||
|
||||
self.assertFalse(SESSION_KEY in self.client.session)
|
||||
|
||||
response = self.client.get(
|
||||
course_private_url,
|
||||
follow=True,
|
||||
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL),
|
||||
HTTP_ACCEPT='text/html'
|
||||
)
|
||||
self.assertEqual(('http://testserver{0}'.format(course_private_url), 302),
|
||||
response.redirect_chain[-1])
|
||||
self.assertIn(SESSION_KEY, self.client.session)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'cms.urls', 'Test only valid in cms')
|
||||
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE)
|
||||
def test_ssl_cms_redirection(self):
|
||||
"""
|
||||
Auto signup auth user and ensure they return to the original
|
||||
url they visited after being logged in.
|
||||
"""
|
||||
course = CourseFactory.create(
|
||||
org='MITx',
|
||||
number='999',
|
||||
display_name='Robot Super Course'
|
||||
)
|
||||
|
||||
external_auth.views.ssl_login(self._create_ssl_request('/'))
|
||||
user = User.objects.get(email=self.USER_EMAIL)
|
||||
CourseEnrollment.enroll(user, course.id)
|
||||
|
||||
CourseStaffRole(course.id).add_users(user)
|
||||
course_private_url = reverse('course_handler', args=(unicode(course.id),))
|
||||
self.assertFalse(SESSION_KEY in self.client.session)
|
||||
|
||||
response = self.client.get(
|
||||
course_private_url,
|
||||
follow=True,
|
||||
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL),
|
||||
HTTP_ACCEPT='text/html'
|
||||
)
|
||||
self.assertEqual(('http://testserver{0}'.format(course_private_url), 302),
|
||||
response.redirect_chain[-1])
|
||||
self.assertIn(SESSION_KEY, self.client.session)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH_AUTO_ACTIVATE)
|
||||
def test_ssl_logout(self):
|
||||
"""
|
||||
Because the branding view is cached for anonymous users and we
|
||||
use that to login users, the browser wasn't actually making the
|
||||
request to that view as the redirect was being cached. This caused
|
||||
a redirect loop, and this test confirms that that won't happen.
|
||||
|
||||
Test is only in LMS because we don't use / in studio to login SSL users.
|
||||
"""
|
||||
response = self.client.get(
|
||||
reverse('dashboard'), follow=True,
|
||||
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL))
|
||||
self.assertEquals(('http://testserver/dashboard', 302),
|
||||
response.redirect_chain[-1])
|
||||
self.assertIn(SESSION_KEY, self.client.session)
|
||||
response = self.client.get(
|
||||
reverse('logout'), follow=True,
|
||||
SSL_CLIENT_S_DN=self.AUTH_DN.format(self.USER_NAME, self.USER_EMAIL)
|
||||
)
|
||||
# Make sure that even though we logged out, we have logged back in
|
||||
self.assertIn(SESSION_KEY, self.client.session)
|
||||
|
||||
@@ -440,7 +440,10 @@ def ssl_login(request):
|
||||
|
||||
(_user, email, fullname) = _ssl_dn_extract_info(cert)
|
||||
|
||||
retfun = functools.partial(redirect, '/')
|
||||
redirect_to = request.GET.get('next')
|
||||
if not redirect_to:
|
||||
redirect_to = '/'
|
||||
retfun = functools.partial(redirect, redirect_to)
|
||||
return _external_login_or_signup(
|
||||
request,
|
||||
external_id=email,
|
||||
@@ -579,14 +582,14 @@ def course_specific_login(request, course_id):
|
||||
course = student.views.course_from_id(course_id)
|
||||
if not course:
|
||||
# couldn't find the course, will just return vanilla signin page
|
||||
return _redirect_with_get_querydict('signin_user', request.GET)
|
||||
return redirect_with_get('signin_user', request.GET)
|
||||
|
||||
# now the dispatching conditionals. Only shib for now
|
||||
if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
|
||||
return _redirect_with_get_querydict('shib-login', request.GET)
|
||||
return redirect_with_get('shib-login', request.GET)
|
||||
|
||||
# Default fallthrough to normal signin page
|
||||
return _redirect_with_get_querydict('signin_user', request.GET)
|
||||
return redirect_with_get('signin_user', request.GET)
|
||||
|
||||
|
||||
def course_specific_register(request, course_id):
|
||||
@@ -598,24 +601,28 @@ def course_specific_register(request, course_id):
|
||||
|
||||
if not course:
|
||||
# couldn't find the course, will just return vanilla registration page
|
||||
return _redirect_with_get_querydict('register_user', request.GET)
|
||||
return redirect_with_get('register_user', request.GET)
|
||||
|
||||
# now the dispatching conditionals. Only shib for now
|
||||
if settings.FEATURES.get('AUTH_USE_SHIB') and course.enrollment_domain.startswith(SHIBBOLETH_DOMAIN_PREFIX):
|
||||
# shib-login takes care of both registration and login flows
|
||||
return _redirect_with_get_querydict('shib-login', request.GET)
|
||||
return redirect_with_get('shib-login', request.GET)
|
||||
|
||||
# Default fallthrough to normal registration page
|
||||
return _redirect_with_get_querydict('register_user', request.GET)
|
||||
return redirect_with_get('register_user', request.GET)
|
||||
|
||||
|
||||
def _redirect_with_get_querydict(view_name, get_querydict):
|
||||
def redirect_with_get(view_name, get_querydict, do_reverse=True):
|
||||
"""
|
||||
Helper function to carry over get parameters across redirects
|
||||
Using urlencode(safe='/') because the @login_required decorator generates 'next' queryparams with '/' unencoded
|
||||
"""
|
||||
if do_reverse:
|
||||
url = reverse(view_name)
|
||||
else:
|
||||
url = view_name
|
||||
if get_querydict:
|
||||
return redirect("%s?%s" % (reverse(view_name), get_querydict.urlencode(safe='/')))
|
||||
return redirect("%s?%s" % (url, get_querydict.urlencode(safe='/')))
|
||||
return redirect(view_name)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
'''
|
||||
Firebase - library to generate a token
|
||||
License: https://github.com/firebase/firebase-token-generator-python/blob/master/LICENSE
|
||||
Tweaked and Edited by @danielcebrianr and @lduarte1991
|
||||
|
||||
This library will take either objects or strings and use python's built-in encoding
|
||||
system as specified by RFC 3548. Thanks to the firebase team for their open-source
|
||||
library. This was made specifically for speaking with the annotation_storage_url and
|
||||
can be used and expanded, but not modified by anyone else needing such a process.
|
||||
'''
|
||||
from base64 import urlsafe_b64encode
|
||||
import hashlib
|
||||
import hmac
|
||||
import sys
|
||||
try:
|
||||
import json
|
||||
except ImportError:
|
||||
import simplejson as json
|
||||
|
||||
__all__ = ['create_token']
|
||||
|
||||
TOKEN_SEP = '.'
|
||||
|
||||
|
||||
def create_token(secret, data):
|
||||
'''
|
||||
Simply takes in the secret key and the data and
|
||||
passes it to the local function _encode_token
|
||||
'''
|
||||
return _encode_token(secret, data)
|
||||
|
||||
|
||||
if sys.version_info < (2, 7):
|
||||
def _encode(bytes_data):
|
||||
'''
|
||||
Takes a json object, string, or binary and
|
||||
uses python's urlsafe_b64encode to encode data
|
||||
and make it safe pass along in a url.
|
||||
To make sure it does not conflict with variables
|
||||
we make sure equal signs are removed.
|
||||
More info: docs.python.org/2/library/base64.html
|
||||
'''
|
||||
encoded = urlsafe_b64encode(bytes(bytes_data))
|
||||
return encoded.decode('utf-8').replace('=', '')
|
||||
else:
|
||||
def _encode(bytes_info):
|
||||
'''
|
||||
Same as above function but for Python 2.7 or later
|
||||
'''
|
||||
encoded = urlsafe_b64encode(bytes_info)
|
||||
return encoded.decode('utf-8').replace('=', '')
|
||||
|
||||
|
||||
def _encode_json(obj):
|
||||
'''
|
||||
Before a python dict object can be properly encoded,
|
||||
it must be transformed into a jason object and then
|
||||
transformed into bytes to be encoded using the function
|
||||
defined above.
|
||||
'''
|
||||
return _encode(bytearray(json.dumps(obj), 'utf-8'))
|
||||
|
||||
|
||||
def _sign(secret, to_sign):
|
||||
'''
|
||||
This function creates a sign that goes at the end of the
|
||||
message that is specific to the secret and not the actual
|
||||
content of the encoded body.
|
||||
More info on hashing: http://docs.python.org/2/library/hmac.html
|
||||
The function creates a hashed values of the secret and to_sign
|
||||
and returns the digested values based the secure hash
|
||||
algorithm, 256
|
||||
'''
|
||||
def portable_bytes(string):
|
||||
'''
|
||||
Simply transforms a string into a bytes object,
|
||||
which is a series of immutable integers 0<=x<=256.
|
||||
Always try to encode as utf-8, unless it is not
|
||||
compliant.
|
||||
'''
|
||||
try:
|
||||
return bytes(string, 'utf-8')
|
||||
except TypeError:
|
||||
return bytes(string)
|
||||
return _encode(hmac.new(portable_bytes(secret), portable_bytes(to_sign), hashlib.sha256).digest()) # pylint: disable=E1101
|
||||
|
||||
|
||||
def _encode_token(secret, claims):
|
||||
'''
|
||||
This is the main function that takes the secret token and
|
||||
the data to be transmitted. There is a header created for decoding
|
||||
purposes. Token_SEP means that a period/full stop separates the
|
||||
header, data object/message, and signatures.
|
||||
'''
|
||||
encoded_header = _encode_json({'typ': 'JWT', 'alg': 'HS256'})
|
||||
encoded_claims = _encode_json(claims)
|
||||
secure_bits = '%s%s%s' % (encoded_header, TOKEN_SEP, encoded_claims)
|
||||
sig = _sign(secret, secure_bits)
|
||||
return '%s%s%s' % (secure_bits, TOKEN_SEP, sig)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
This test will run for firebase_token_generator.py.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from student.firebase_token_generator import _encode, _encode_json, _encode_token, create_token
|
||||
|
||||
|
||||
class TokenGenerator(TestCase):
|
||||
"""
|
||||
Tests for the file firebase_token_generator.py
|
||||
"""
|
||||
def test_encode(self):
|
||||
"""
|
||||
This tests makes sure that no matter what version of python
|
||||
you have, the _encode function still returns the appropriate result
|
||||
for a string.
|
||||
"""
|
||||
expected = "dGVzdDE"
|
||||
result = _encode("test1")
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_encode_json(self):
|
||||
"""
|
||||
Same as above, but this one focuses on a python dict type
|
||||
transformed into a json object and then encoded.
|
||||
"""
|
||||
expected = "eyJ0d28iOiAidGVzdDIiLCAib25lIjogInRlc3QxIn0"
|
||||
result = _encode_json({'one': 'test1', 'two': 'test2'})
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_create_token(self):
|
||||
"""
|
||||
Unlike its counterpart in student/views.py, this function
|
||||
just checks for the encoding of a token. The other function
|
||||
will test depending on time and user.
|
||||
"""
|
||||
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.-p1sr7uwCapidTQ0qB7DdU2dbF-hViKpPNN_5vD10t8"
|
||||
result1 = _encode_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
|
||||
result2 = create_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
|
||||
self.assertEqual(expected, result1)
|
||||
self.assertEqual(expected, result2)
|
||||
@@ -12,21 +12,22 @@ 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,
|
||||
change_enrollment, complete_course_mode_info, token)
|
||||
change_enrollment, complete_course_mode_info)
|
||||
from student.tests.factories import UserFactory, CourseModeFactory
|
||||
|
||||
import shoppingcart
|
||||
@@ -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_key, 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_key, 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
|
||||
@@ -451,26 +491,3 @@ class AnonymousLookupTable(TestCase):
|
||||
anonymous_id = anonymous_id_for_user(self.user, self.course.id)
|
||||
real_user = user_by_anonymous_id(anonymous_id)
|
||||
self.assertEqual(self.user, real_user)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class Token(ModuleStoreTestCase):
|
||||
"""
|
||||
Test for the token generator. This creates a random course and passes it through the token file which generates the
|
||||
token that will be passed in to the annotation_storage_url.
|
||||
"""
|
||||
request_factory = RequestFactory()
|
||||
COURSE_SLUG = "100"
|
||||
COURSE_NAME = "test_course"
|
||||
COURSE_ORG = "edx"
|
||||
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
|
||||
self.user = User.objects.create(username="username", email="username")
|
||||
self.req = self.request_factory.post('/token?course_id=edx/100/test_course', {'user': self.user})
|
||||
self.req.user = self.user
|
||||
|
||||
def test_token(self):
|
||||
expected = HttpResponse("eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAxLTIzVDE5OjM1OjE3LjUyMjEwNC01OjAwIiwgImNvbnN1bWVyS2V5IjogInh4eHh4eHh4LXh4eHgteHh4eC14eHh4LXh4eHh4eHh4eHh4eCIsICJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.OjWz9mzqJnYuzX-f3uCBllqJUa8PVWJjcDy_McfxLvc", mimetype="text/plain")
|
||||
response = token(self.req)
|
||||
self.assertEqual(expected.content.split('.')[0], response.content.split('.')[0])
|
||||
|
||||
@@ -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
|
||||
@@ -29,6 +26,7 @@ from django.shortcuts import redirect
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.utils.http import cookie_date, base36_to_int
|
||||
from django.utils.translation import ugettext as _, get_language
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_POST, require_GET
|
||||
|
||||
from django.template.response import TemplateResponse
|
||||
@@ -46,7 +44,6 @@ from student.models import (
|
||||
create_comments_service_user, PasswordHistory
|
||||
)
|
||||
from student.forms import PasswordResetFormNoActive
|
||||
from student.firebase_token_generator import create_token
|
||||
|
||||
from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow
|
||||
from certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
@@ -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,
|
||||
@@ -356,7 +328,7 @@ def signin_user(request):
|
||||
# SSL login doesn't require a view, so redirect
|
||||
# branding and allow that to process the login if it
|
||||
# is enabled and the header is in the request.
|
||||
return redirect(reverse('root'))
|
||||
return external_auth.views.redirect_with_get('root', request.GET)
|
||||
if settings.FEATURES.get('AUTH_USE_CAS'):
|
||||
# If CAS is enabled, redirect auth handling to there
|
||||
return redirect(reverse('cas-login'))
|
||||
@@ -389,7 +361,7 @@ def register_user(request, extra_context=None):
|
||||
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'):
|
||||
# Redirect to branding to process their certificate if SSL is enabled
|
||||
# and registration is disabled.
|
||||
return redirect(reverse('root'))
|
||||
return external_auth.views.redirect_with_get('root', request.GET)
|
||||
|
||||
context = {
|
||||
'course_id': request.GET.get('course_id'),
|
||||
@@ -704,6 +676,7 @@ def _get_course_enrollment_domain(course_id):
|
||||
return course.enrollment_domain
|
||||
|
||||
|
||||
@never_cache
|
||||
@ensure_csrf_cookie
|
||||
def accounts_login(request):
|
||||
"""
|
||||
@@ -713,9 +686,9 @@ def accounts_login(request):
|
||||
if settings.FEATURES.get('AUTH_USE_CAS'):
|
||||
return redirect(reverse('cas-login'))
|
||||
if settings.FEATURES['AUTH_USE_CERTIFICATES']:
|
||||
# SSL login doesn't require a view, so redirect
|
||||
# to branding and allow that to process the login.
|
||||
return redirect(reverse('root'))
|
||||
# SSL login doesn't require a view, so login
|
||||
# directly here
|
||||
return external_auth.views.ssl_login(request)
|
||||
# see if the "next" parameter has been set, whether it has a course context, and if so, whether
|
||||
# there is a course-specific place to redirect
|
||||
redirect_to = request.GET.get('next')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -429,16 +429,16 @@ class LoncapaProblem(object):
|
||||
|
||||
def do_targeted_feedback(self, tree):
|
||||
"""
|
||||
Implements the targeted-feedback=N in-place on <multiplechoiceresponse> --
|
||||
Implements targeted-feedback in-place on <multiplechoiceresponse> --
|
||||
choice-level explanations shown to a student after submission.
|
||||
Does nothing if there is no targeted-feedback attribute.
|
||||
"""
|
||||
for mult_choice_response in tree.xpath('//multiplechoiceresponse[@targeted-feedback]'):
|
||||
# Note that the modifications has been done, avoiding problems if called twice.
|
||||
if hasattr(self, 'has_targeted'):
|
||||
continue
|
||||
self.has_targeted = True # pylint: disable=W0201
|
||||
# Note that the modifications has been done, avoiding problems if called twice.
|
||||
if hasattr(self, 'has_targeted'):
|
||||
return
|
||||
self.has_targeted = True # pylint: disable=W0201
|
||||
|
||||
for mult_choice_response in tree.xpath('//multiplechoiceresponse[@targeted-feedback]'):
|
||||
show_explanation = mult_choice_response.get('targeted-feedback') == 'alwaysShowCorrectChoiceExplanation'
|
||||
|
||||
# Grab the first choicegroup (there should only be one within each <multiplechoiceresponse> tag)
|
||||
@@ -620,6 +620,7 @@ class LoncapaProblem(object):
|
||||
"""
|
||||
context = {}
|
||||
context['seed'] = self.seed
|
||||
context['anonymous_student_id'] = self.capa_system.anonymous_student_id
|
||||
all_code = ''
|
||||
|
||||
python_path = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="external-grader-message" aria-live="polite">
|
||||
${msg|n}
|
||||
</div>
|
||||
<div class="external-grader-message" aria-live="polite">
|
||||
<div class="external-grader-message ungraded-matlab-result" aria-live="polite">
|
||||
${queue_msg|n}
|
||||
</div>
|
||||
|
||||
@@ -55,13 +55,11 @@
|
||||
if($(parent_elt).find('.capa_alert').length) {
|
||||
$(parent_elt).find('.capa_alert').remove();
|
||||
}
|
||||
var alert_elem = "<div>" + msg + "</div>";
|
||||
alert_elem = $(alert_elem).addClass('capa_alert');
|
||||
var alert_elem = $("<div>" + msg + "</div>");
|
||||
alert_elem.addClass('capa_alert').addClass('is-fading-in');
|
||||
$(parent_elt).find('.action').after(alert_elem);
|
||||
$(parent_elt).find('.capa_alert').css({opacity: 0}).animate({opacity: 1}, 700);
|
||||
}
|
||||
|
||||
|
||||
// hook up the plot button
|
||||
var plot = function(event) {
|
||||
var problem_elt = $(event.target).closest('.problems-wrapper');
|
||||
@@ -72,7 +70,7 @@
|
||||
// since there could be multiple codemirror instances on the page,
|
||||
// save all of them.
|
||||
$('.CodeMirror').each(function(i, el){
|
||||
el.CodeMirror.save();
|
||||
el.CodeMirror.save();
|
||||
});
|
||||
var input = $("#input_${id}");
|
||||
|
||||
@@ -81,33 +79,39 @@
|
||||
|
||||
answer = input.serialize();
|
||||
|
||||
// setup callback for after we send information to plot
|
||||
// a chain of callbacks, each querying the server on success of the previous one
|
||||
|
||||
var get_callback = function(response) {
|
||||
var new_result_elem = $(response.html).find(".ungraded-matlab-result");
|
||||
new_result_elem.addClass("is-fading-in");
|
||||
result_elem = $(problem_elt).find(".ungraded-matlab-result");
|
||||
result_elem.replaceWith(new_result_elem);
|
||||
console.log(response.html);
|
||||
}
|
||||
|
||||
var plot_callback = function(response) {
|
||||
if(response.success) {
|
||||
window.location.reload();
|
||||
$.postWithPrefix(url + "/problem_get", get_callback);
|
||||
} else {
|
||||
gentle_alert(problem_elt, response.message);
|
||||
}
|
||||
}
|
||||
|
||||
var save_callback = function(response) {
|
||||
if(response.success) {
|
||||
// send information to the problem's plot functionality
|
||||
Problem.inputAjax(url, input_id, 'plot',
|
||||
{'submission': submission}, plot_callback);
|
||||
}
|
||||
else {
|
||||
gentle_alert(problem_elt, response.message);
|
||||
}
|
||||
}
|
||||
|
||||
var save_callback = function(response) {
|
||||
if(response.success) {
|
||||
// send information to the problem's plot functionality
|
||||
Problem.inputAjax(url, input_id, 'plot',
|
||||
{'submission': submission}, plot_callback);
|
||||
}
|
||||
else {
|
||||
gentle_alert(problem_elt, response.message);
|
||||
}
|
||||
}
|
||||
|
||||
// save the answer
|
||||
$.postWithPrefix(url + '/problem_save', answer, save_callback);
|
||||
|
||||
}
|
||||
$('#plot_${id}').click(plot);
|
||||
|
||||
});
|
||||
</script>
|
||||
</section>
|
||||
|
||||
@@ -57,3 +57,15 @@ def test_capa_system():
|
||||
def new_loncapa_problem(xml, capa_system=None, seed=723):
|
||||
"""Construct a `LoncapaProblem` suitable for unit tests."""
|
||||
return LoncapaProblem(xml, id='1', seed=seed, capa_system=capa_system or test_capa_system())
|
||||
|
||||
|
||||
def load_fixture(relpath):
|
||||
"""
|
||||
Return a `unicode` object representing the contents
|
||||
of the fixture file at the given path within a test_files directory
|
||||
in the same directory as the test file.
|
||||
"""
|
||||
abspath = os.path.join(os.path.dirname(__file__), 'test_files', relpath)
|
||||
with open(abspath) as fixture_file:
|
||||
contents = fixture_file.read()
|
||||
return contents.decode('utf8')
|
||||
|
||||
50
common/lib/capa/capa/tests/test_files/targeted_feedback.xml
Normal file
50
common/lib/capa/capa/tests/test_files/targeted_feedback.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<problem>
|
||||
<p>What is the correct answer?</p>
|
||||
<multiplechoiceresponse targeted-feedback="">
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
|
||||
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
|
||||
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
|
||||
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
<targetedfeedbackset>
|
||||
<targetedfeedback explanation-id="feedback1">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 1st WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedback2">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 2nd WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedback3">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 3rd WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedbackC">
|
||||
<div class="detailed-targeted-feedback-correct">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>Feedback on your correct solution...</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
</targetedfeedbackset>
|
||||
|
||||
<solution explanation-id="feedbackC">
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p>This is the solution explanation</p>
|
||||
<p>Not much to explain here, sorry!</p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>
|
||||
@@ -0,0 +1,91 @@
|
||||
<problem>
|
||||
<p>Q1</p>
|
||||
<multiplechoiceresponse targeted-feedback="">
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
|
||||
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
|
||||
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
|
||||
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
<targetedfeedbackset>
|
||||
<targetedfeedback explanation-id="feedback1">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 1st WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedback3">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 3rd WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedbackC">
|
||||
<div class="detailed-targeted-feedback-correct">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>Feedback on your correct solution...</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
</targetedfeedbackset>
|
||||
|
||||
<solutionset>
|
||||
<solution explanation-id="feedbackC">
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p>This is the solution explanation</p>
|
||||
<p>Not much to explain here, sorry!</p>
|
||||
</div>
|
||||
</solution>
|
||||
</solutionset>
|
||||
|
||||
<hr/>
|
||||
|
||||
<p>Q2</p>
|
||||
<multiplechoiceresponse targeted-feedback="">
|
||||
<choicegroup type="MultipleChoice" answer-pool="3">
|
||||
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
|
||||
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
|
||||
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
|
||||
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
<targetedfeedbackset>
|
||||
<targetedfeedback explanation-id="feedback1">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 1st WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedback3">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 3rd WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedbackC">
|
||||
<div class="detailed-targeted-feedback-correct">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>Feedback on your correct solution...</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
</targetedfeedbackset>
|
||||
|
||||
<solutionset>
|
||||
<solution explanation-id="feedbackC">
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p>This is the solution explanation</p>
|
||||
<p>Not much to explain here, sorry!</p>
|
||||
</div>
|
||||
</solution>
|
||||
</solutionset>
|
||||
</problem>
|
||||
@@ -73,6 +73,24 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
span_element = rendered_html.find('span')
|
||||
self.assertEqual(span_element.text, 'Test text')
|
||||
|
||||
def test_anonymous_student_id(self):
|
||||
# make sure anonymous_student_id is rendered properly as a context variable
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<span>Welcome $anonymous_student_id</span>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem
|
||||
problem = new_loncapa_problem(xml_str)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the anonymous_student_id was converted to "student"
|
||||
span_element = rendered_html.find('span')
|
||||
self.assertEqual(span_element.text, 'Welcome student')
|
||||
|
||||
def test_render_script(self):
|
||||
# Generate some XML with a <script> tag
|
||||
xml_str = textwrap.dedent("""
|
||||
|
||||
@@ -13,7 +13,7 @@ import textwrap
|
||||
import requests
|
||||
import mock
|
||||
|
||||
from . import new_loncapa_problem, test_capa_system
|
||||
from . import new_loncapa_problem, test_capa_system, load_fixture
|
||||
import calc
|
||||
|
||||
from capa.responsetypes import LoncapaProblemError, \
|
||||
@@ -224,7 +224,7 @@ class SymbolicResponseTest(ResponseTest):
|
||||
|
||||
for (input_str, input_mathml, server_fixture) in correct_inputs:
|
||||
print "Testing input: {0}".format(input_str)
|
||||
server_resp = self._load_fixture(server_fixture)
|
||||
server_resp = load_fixture(server_fixture)
|
||||
self._assert_symbolic_grade(
|
||||
problem, input_str, input_mathml,
|
||||
'correct', snuggletex_resp=server_resp
|
||||
@@ -253,8 +253,8 @@ class SymbolicResponseTest(ResponseTest):
|
||||
options=["matrix", "imaginary"]
|
||||
)
|
||||
|
||||
correct_snuggletex = self._load_fixture('snuggletex_correct.html')
|
||||
dynamath_input = self._load_fixture('dynamath_input.txt')
|
||||
correct_snuggletex = load_fixture('snuggletex_correct.html')
|
||||
dynamath_input = load_fixture('dynamath_input.txt')
|
||||
student_response = "cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]"
|
||||
|
||||
self._assert_symbolic_grade(
|
||||
@@ -269,7 +269,7 @@ class SymbolicResponseTest(ResponseTest):
|
||||
expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]",
|
||||
options=["matrix", "imaginary"])
|
||||
|
||||
wrong_snuggletex = self._load_fixture('snuggletex_wrong.html')
|
||||
wrong_snuggletex = load_fixture('snuggletex_wrong.html')
|
||||
dynamath_input = textwrap.dedent("""
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<mstyle displaystyle="true"><mn>2</mn></mstyle>
|
||||
@@ -315,18 +315,6 @@ class SymbolicResponseTest(ResponseTest):
|
||||
correct_map.get_correctness('1_2_1'), expected_correctness
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_fixture(relpath):
|
||||
"""
|
||||
Return a `unicode` object representing the contents
|
||||
of the fixture file at `relpath` (relative to the test files dir)
|
||||
"""
|
||||
abspath = os.path.join(os.path.dirname(__file__), 'test_files', relpath)
|
||||
with open(abspath) as fixture_file:
|
||||
contents = fixture_file.read()
|
||||
|
||||
return contents.decode('utf8')
|
||||
|
||||
|
||||
class OptionResponseTest(ResponseTest):
|
||||
from capa.tests.response_xml_factory import OptionResponseXMLFactory
|
||||
|
||||
@@ -5,7 +5,7 @@ i.e. those with the <multiplechoiceresponse> element
|
||||
|
||||
import unittest
|
||||
import textwrap
|
||||
from . import test_capa_system, new_loncapa_problem
|
||||
from . import test_capa_system, new_loncapa_problem, load_fixture
|
||||
|
||||
|
||||
class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
@@ -80,62 +80,8 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertRegexpMatches(without_new_lines, r"<div>.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*</div>")
|
||||
self.assertRegexpMatches(without_new_lines, r"feedback1|feedback2|feedback3|feedbackC")
|
||||
|
||||
# A targeted-feedback problem shared for a few tests
|
||||
common_targeted_xml = textwrap.dedent("""
|
||||
<problem>
|
||||
<p>What is the correct answer?</p>
|
||||
<multiplechoiceresponse targeted-feedback="">
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false" explanation-id="feedback1">wrong-1</choice>
|
||||
<choice correct="false" explanation-id="feedback2">wrong-2</choice>
|
||||
<choice correct="true" explanation-id="feedbackC">correct-1</choice>
|
||||
<choice correct="false" explanation-id="feedback3">wrong-3</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
|
||||
<targetedfeedbackset>
|
||||
<targetedfeedback explanation-id="feedback1">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 1st WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedback2">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 2nd WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedback3">
|
||||
<div class="detailed-targeted-feedback">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>This is the 3rd WRONG solution</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
<targetedfeedback explanation-id="feedbackC">
|
||||
<div class="detailed-targeted-feedback-correct">
|
||||
<p>Targeted Feedback</p>
|
||||
<p>Feedback on your correct solution...</p>
|
||||
</div>
|
||||
</targetedfeedback>
|
||||
|
||||
</targetedfeedbackset>
|
||||
|
||||
<solution explanation-id="feedbackC">
|
||||
<div class="detailed-solution">
|
||||
<p>Explanation</p>
|
||||
<p>This is the solution explanation</p>
|
||||
<p>Not much to explain here, sorry!</p>
|
||||
</div>
|
||||
</solution>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
def test_targeted_feedback_not_finished(self):
|
||||
problem = new_loncapa_problem(self.common_targeted_xml)
|
||||
problem = new_loncapa_problem(load_fixture('targeted_feedback.xml'))
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\n", "")
|
||||
|
||||
@@ -144,7 +90,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertEquals(the_html, problem.get_html(), "Should be able to call get_html() twice")
|
||||
|
||||
def test_targeted_feedback_student_answer1(self):
|
||||
problem = new_loncapa_problem(self.common_targeted_xml)
|
||||
problem = new_loncapa_problem(load_fixture('targeted_feedback.xml'))
|
||||
problem.done = True
|
||||
problem.student_answers = {'1_2_1': 'choice_3'}
|
||||
|
||||
@@ -158,7 +104,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertEquals(the_html, the_html2)
|
||||
|
||||
def test_targeted_feedback_student_answer2(self):
|
||||
problem = new_loncapa_problem(self.common_targeted_xml)
|
||||
problem = new_loncapa_problem(load_fixture('targeted_feedback.xml'))
|
||||
problem.done = True
|
||||
problem.student_answers = {'1_2_1': 'choice_0'}
|
||||
|
||||
@@ -611,3 +557,41 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertNotRegexpMatches(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
|
||||
self.assertRegexpMatches(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
|
||||
self.assertNotRegexpMatches(without_new_lines, r"feedback1|feedback3|feedbackC")
|
||||
|
||||
def test_targeted_feedback_multiple_not_answered(self):
|
||||
# Not answered -> empty targeted feedback
|
||||
problem = new_loncapa_problem(load_fixture('targeted_feedback_multiple.xml'))
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\n", "")
|
||||
# Q1 and Q2 have no feedback
|
||||
self.assertRegexpMatches(
|
||||
without_new_lines,
|
||||
r'<targetedfeedbackset.*?>\s*</targetedfeedbackset>.*' +
|
||||
r'<targetedfeedbackset.*?>\s*</targetedfeedbackset>'
|
||||
)
|
||||
|
||||
def test_targeted_feedback_multiple_answer_1(self):
|
||||
problem = new_loncapa_problem(load_fixture('targeted_feedback_multiple.xml'))
|
||||
problem.done = True
|
||||
problem.student_answers = {'1_2_1': 'choice_0'} # feedback1
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\n", "")
|
||||
# Q1 has feedback1 and Q2 has nothing
|
||||
self.assertRegexpMatches(
|
||||
without_new_lines,
|
||||
r'<targetedfeedbackset.*?>.*?explanation-id="feedback1".*?</targetedfeedbackset>.*' +
|
||||
r'<targetedfeedbackset.*?>\s*</targetedfeedbackset>'
|
||||
)
|
||||
|
||||
def test_targeted_feedback_multiple_answer_2(self):
|
||||
problem = new_loncapa_problem(load_fixture('targeted_feedback_multiple.xml'))
|
||||
problem.done = True
|
||||
problem.student_answers = {'1_2_1': 'choice_0', '1_3_1': 'mask_1'} # Q1 wrong, Q2 correct
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\n", "")
|
||||
# Q1 has feedback1 and Q2 has feedbackC
|
||||
self.assertRegexpMatches(
|
||||
without_new_lines,
|
||||
r'<targetedfeedbackset.*?>.*?explanation-id="feedback1".*?</targetedfeedbackset>.*' +
|
||||
r'<targetedfeedbackset.*?>.*explanation-id="feedbackC".*?</targetedfeedbackset>'
|
||||
)
|
||||
|
||||
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
This file contains a function used to retrieve the token for the annotation backend
|
||||
without having to create a view, but just returning a string instead.
|
||||
|
||||
It can be called from other files by using the following:
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
"""
|
||||
import datetime
|
||||
from firebase_token_generator import create_token
|
||||
|
||||
|
||||
def retrieve_token(userid, secret):
|
||||
'''
|
||||
Return a token for the backend of annotations.
|
||||
It uses the course id to retrieve a variable that contains the secret
|
||||
token found in inheritance.py. It also contains information of when
|
||||
the token was issued. This will be stored with the user along with
|
||||
the id for identification purposes in the backend.
|
||||
'''
|
||||
|
||||
# the following five lines of code allows you to include the default timezone in the iso format
|
||||
# for more information: http://stackoverflow.com/questions/3401428/how-to-get-an-isoformat-datetime-string-including-the-default-timezone
|
||||
dtnow = datetime.datetime.now()
|
||||
dtutcnow = datetime.datetime.utcnow()
|
||||
delta = dtnow - dtutcnow
|
||||
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
|
||||
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
|
||||
# uses the issued time (UTC plus timezone), the consumer key and the user's email to maintain a
|
||||
# federated system in the annotation backend server
|
||||
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": userid, "ttl": 86400}
|
||||
newtoken = create_token(secret, custom_data)
|
||||
return newtoken
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ describe 'CombinedOpenEnded', ->
|
||||
expect(window.setTimeout).toHaveBeenCalledWith(@combined.poll, 10000)
|
||||
expect(window.queuePollerID).toBe(5)
|
||||
|
||||
it 'polling stops properly', =>
|
||||
xit 'polling stops properly', =>
|
||||
fakeResponseDone = state: "done"
|
||||
spyOn($, 'postWithPrefix').andCallFake (url, callback) -> callback(fakeResponseDone)
|
||||
@combined.poll()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -177,59 +177,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
describe('YouTube video in FireFox will cue first', function () {
|
||||
var oldUserAgent;
|
||||
|
||||
beforeEach(function () {
|
||||
oldUserAgent = window.navigator.userAgent;
|
||||
window.navigator.userAgent = 'firefox';
|
||||
|
||||
state = jasmine.initializePlayer('video.html', {
|
||||
start: 10,
|
||||
end: 30
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
window.navigator.userAgent = oldUserAgent;
|
||||
});
|
||||
|
||||
it('cue is called, skipOnEndedStartEndReset is set', function () {
|
||||
state.videoPlayer.updatePlayTime(10);
|
||||
expect(state.videoPlayer.player.cueVideoById).toHaveBeenCalledWith('cogebirgzzM', 10);
|
||||
expect(state.videoPlayer.skipOnEndedStartEndReset).toBe(true);
|
||||
});
|
||||
|
||||
it('when position is not 0: cue is called with stored position value', function () {
|
||||
state.config.savedVideoPosition = 15;
|
||||
|
||||
state.videoPlayer.updatePlayTime(10);
|
||||
expect(state.videoPlayer.player.cueVideoById).toHaveBeenCalledWith('cogebirgzzM', 15);
|
||||
});
|
||||
|
||||
it('Handling cue state', function () {
|
||||
spyOn(state.videoPlayer, 'play');
|
||||
|
||||
state.videoPlayer.seekToTimeOnCued = 10;
|
||||
state.videoPlayer.onStateChange({data: 5});
|
||||
|
||||
expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(10, true);
|
||||
expect(state.videoPlayer.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('even when cued, onEnded does not resets start and end time', function () {
|
||||
state.videoPlayer.skipOnEndedStartEndReset = true;
|
||||
state.videoPlayer.onEnded();
|
||||
expect(state.videoPlayer.startTime).toBe(10);
|
||||
expect(state.videoPlayer.endTime).toBe(30);
|
||||
|
||||
state.videoPlayer.skipOnEndedStartEndReset = undefined;
|
||||
state.videoPlayer.onEnded();
|
||||
expect(state.videoPlayer.startTime).toBe(10);
|
||||
expect(state.videoPlayer.endTime).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checking start and end times', function () {
|
||||
var miniTestSuite = [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
afterEach(function () {
|
||||
$('source').remove();
|
||||
state.storage.clear();
|
||||
window.Video.previousState = null;
|
||||
window.onTouchBasedDevice = oldOTBD;
|
||||
});
|
||||
|
||||
@@ -37,7 +38,7 @@
|
||||
});
|
||||
|
||||
it('add ARIA attributes to time control', function () {
|
||||
var timeControl = $('div.slider>a');
|
||||
var timeControl = $('div.slider > a');
|
||||
|
||||
expect(timeControl).toHaveAttrs({
|
||||
'role': 'slider',
|
||||
@@ -135,8 +136,6 @@
|
||||
|
||||
expectedValue = sliderEl.slider('option', 'value');
|
||||
expect(expectedValue).toBe(10);
|
||||
|
||||
state.storage.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -389,7 +388,7 @@
|
||||
runs(function () {
|
||||
state = jasmine.initializePlayer({
|
||||
end: 20,
|
||||
savedVideoPosition: 'a'
|
||||
savedVideoPosition: 'a'
|
||||
});
|
||||
sliderEl = state.videoProgressSlider.slider;
|
||||
spyOn(state.videoPlayer, 'duration').andReturn(60);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user