Merge pull request #3673 from cpennington/opaque-keys-merge-master

Merge master into opaque-keys
This commit is contained in:
Calen Pennington
2014-05-15 13:24:50 -04:00
677 changed files with 173811 additions and 19999 deletions

View File

@@ -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>

View File

@@ -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.

View File

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

View File

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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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))

View File

@@ -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

View File

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

View File

@@ -33,6 +33,7 @@ from ..utils import get_modulestore
from .access import has_course_access
from .helpers import _xmodule_recurse
from contentstore.utils import compute_publish_state, PublishState
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from contentstore.views.preview import get_preview_fragment
from edxmako.shortcuts import render_to_string
from models.settings.course_grading import CourseGradingModel
@@ -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):
"""

View File

@@ -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'))

View File

@@ -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)

View File

@@ -4,6 +4,7 @@ import json
from contentstore.views import tabs
from contentstore.tests.utils import CourseTestCase
from django.test import TestCase
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.tabs import CourseTabList, WikiTab
from contentstore.utils import reverse_course_url
@@ -23,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"""

View File

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

View File

@@ -260,7 +260,6 @@ SITE_ID = 1
SITE_NAME = "localhost:8001"
HTTPS = 'on'
ROOT_URLCONF = 'cms.urls'
IGNORABLE_404_ENDS = ('favicon.ico')
# Email
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
@@ -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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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];

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -179,10 +179,6 @@
height: 365px;
}
&.modal-type-problem .CodeMirror {
height: 435px;
}
.wrapper-comp-settings {
.list-input {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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>

View File

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

View File

@@ -1,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>

View File

@@ -1,10 +1,10 @@
<div class="transcripts-message-status status-error">
<i class="icon-remove"></i>
<%= gettext("Timed Transcript Not Updated") %>
<%= gettext("Confirm Timed Transcript") %>
</div>
<p class="transcripts-message">
<%= gettext("You changed a video source, but did not update the timed transcript file. Do you want to upload new timed transcript?") %>
<%= gettext("You changed a video URL, but did not change the timed transcript file. Do you want to use the current timed transcript or upload a new .srt transcript file?") %>
</p>
<div class="transcripts-file-uploader"></div>
@@ -18,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>

View File

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

View File

@@ -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),
)

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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])

View File

@@ -2,10 +2,8 @@
Student Views
"""
import datetime
import json
import logging
import re
import urllib
import uuid
import time
from collections import defaultdict
@@ -17,7 +15,6 @@ from django.contrib.auth.models import User, AnonymousUser
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import password_reset_confirm
from django.contrib import messages
from django.core.cache import cache
from django.core.context_processors import csrf
from django.core.mail import send_mail
from django.core.urlresolvers import reverse
@@ -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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = []

View File

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

View File

@@ -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>

View File

@@ -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')

View 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>

View File

@@ -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>

View File

@@ -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("""

View File

@@ -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

View File

@@ -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>'
)

View 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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

@@ -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 = [
{

View File

@@ -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