diff --git a/AUTHORS b/AUTHORS index 3f6af3d375..138fff37fb 100644 --- a/AUTHORS +++ b/AUTHORS @@ -142,3 +142,4 @@ Marco Re Jonas Jelten Christine Lytwynec John Cox +Ben Weeks diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2d1af894cf..a92773515f 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -2,26 +2,57 @@ Contributing to edx-platform ############################ -Contributions to edx-platform are very welcome, and strongly encouraged! The -easiest way is to fork the repo and then make a pull request from your fork. -Check out our `process documentation`_, or read on for details on how to -become a contributor, edx-platform code quality, testing, making a pull -request, and more. +Contributions to edx-platform are very welcome, and strongly encouraged! We've +put together `some documentation that describes our contribution process`_, +but here's a step-by-step guide that should help you get started. -.. _process documentation: https://github.com/edx/edx-platform/blob/master/docs/en_us/developers/source/process/index.rst +.. _some documentation that describes our contribution process: http://edx.readthedocs.org/projects/userdocs/en/latest/process/overview.html -Becoming a Contributor -====================== +Step 0: Join the Conversation +============================= -Before your first pull request is merged, you'll need to sign the `individual -contributor agreement`_ and send it in. This confirms you have the authority to -contribute the code in the pull request and ensures we can relicense it. +Got an idea for how to improve the codebase? Fantastic, we'd love to hear about +it! Before you dive in and spend a lot of time and effort making a pull request, +it's a good idea to discuss your idea with other interested developers. You may +get some valuable feedback that changes how you think about your idea, or you +may find other developers who have the same idea and want to work together. + +For real-time conversation, we use `IRC`_: we all hang out in the +`#edx-code channel on Freenode`_. Come join us! The channel tends to be most +active Monday through Friday between 13:00 and 21:00 UTC +(9am to 5pm US Eastern time), but interesting conversations can happen +at any time. + +.. _IRC: http://www.irchelp.org/ +.. _#edx-code channel on Freenode: http://webchat.freenode.net/?channels=edx-code + +For asynchronous conversation, we have several mailing lists on Google Groups: + +* `openedx-ops`_: everything related to *running* Open edX. This includes + installation issues, server management, cost analysis, and so on. +* `openedx-translation`_: everything related to *translating* Open edX into + other languages. This includes volunteer translators, our internationalization + infrastructure, issues related to Transifex, and so on. +* `edx-code`_: everything related to the *code* in Open edX. This includes + feature requests, idea proposals, refactorings, and so on. + +.. _openedx-ops: https://groups.google.com/forum/#!forum/openedx-ops +.. _openedx-translation: https://groups.google.com/forum/#!forum/openedx-translation +.. _edx-code: https://groups.google.com/forum/#!forum/edx-code + +Step 1: Sign a Contribution Agreement +===================================== + +Before edX can accept any code contributions from you, you'll need to sign +the `individual contributor agreement`_ and send it in. This confirms +that you have the authority to contribute the code in the pull request and +ensures that edX can relicense it. You should print out the agreement and sign it. Then scan (or photograph) the signed agreement and email it to the email address indicated on the agreement. Alternatively, you're also free to physically mail the agreement to the street address on the agreement. Once we have your agreement in hand, we can begin -merging your work. +reviewing and merging your work. You'll also need to add yourself to the `AUTHORS` file when you submit your first pull request. You should add your full name as well as the email address @@ -31,156 +62,69 @@ request to contain multiple commits, including a commit to `AUTHORS`). Alternatively, you can open up a separate PR just to have your name added to the `AUTHORS` file, and link that PR to the PR with your changes. +Step 2: Fork, Commit, and Pull Request +====================================== +Github has some great documentation on `how to fork a git repository`_. Once +you've done that, make your changes and `send us a pull request`_! Be sure to +include a detailed description for your pull request, so that a community +manager can understand *what* change you're making, *why* you're making it, *how* +it should work now, and how you can *test* that it's working correctly. -Code Quality Guidelines -======================= +.. _how to fork a git repository: https://help.github.com/articles/fork-a-repo +.. _send us a pull request: https://help.github.com/articles/creating-a-pull-request -Comments --------- +Step 3: Meet PR Requirements +============================ -We expect you to contribute code that is self-documenting as much as possible. -This means submitting code with well-formed variable, function, class, and -method names; good docstrings; lots of comments. Use your discretion - not -every line needs to be commented. However, code that is obtuse is hard to -maintain and hard for others to build upon. So please do your best to provide -code that is easy to read and well-commented. +Our `contributor documentation`_ includes a long list of requirements that pull +requests must meet in order to be reviewed by a core committer. These requirements +include things like documentation and passing tests: see the +`contributor documentation`_ page for the full list. -Python/Javascript Styling -------------------------- +.. _contributor documentation: http://edx.readthedocs.org/projects/userdocs/en/latest/process/contributor.html -Before you submit your first pull request, please review the edx-platform code -quality and style guidelines: +Step 4: Approval by Community Manager and Product Owner +======================================================= -* `Python Guidelines `_ -* `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 `_ - 2. `Git Docs `_ - 3. `Interactive Git tutorial `_ -- totally awesome!! - 4. `Git Ready `_ - - -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 `_ -* `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 `_ is easiest, because you -don't need to install anything and it's cross-platform. `ChatZilla -`_ is almost as easy -- it's a Firefox -extension, and works anywhere Firefox does. For an installed application, -`Pidgin `_ works decently (or `Adium `_ on -Mac), and has a familiar instant-messenger-style interface. For something truly -dedicated to IRC, there's `mIRC `_ for Windows (free), -`LimeChat `_ for Mac (free), or `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 + diff --git a/README.md b/README.md index 4e01b44e38..24a65cebf3 100644 --- a/README.md +++ b/README.md @@ -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 ----------------- diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 67fc78e644..2a854b7c8a 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -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', []) diff --git a/cms/envs/common.py b/cms/envs/common.py index 9e199e3372..85eadd25ca 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -546,7 +546,7 @@ COURSES_WITH_UNSAFE_CODE = [] ############################## EVENT TRACKING ################################# -TRACK_MAX_EVENT = 10000 +TRACK_MAX_EVENT = 50000 TRACKING_BACKENDS = { 'logger': { @@ -557,6 +557,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 +585,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 diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index 8b2bc885d0..93371a1934 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -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() diff --git a/cms/static/js/collections/course_relative.js b/cms/static/js/collections/course_relative.js deleted file mode 100644 index 5cb4fb4c31..0000000000 --- a/cms/static/js/collections/course_relative.js +++ /dev/null @@ -1,6 +0,0 @@ -define(["backbone", "js/models/course_relative"], function(Backbone, CourseRelativeModel) { - var CourseRelativeCollection = Backbone.Collection.extend({ - model: CourseRelativeModel - }); - return CourseRelativeCollection; -}); diff --git a/cms/static/js/models/course_relative.js b/cms/static/js/models/course_relative.js deleted file mode 100644 index 760b950062..0000000000 --- a/cms/static/js/models/course_relative.js +++ /dev/null @@ -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; -}); diff --git a/cms/static/js/spec/views/baseview_spec.js b/cms/static/js/spec/views/baseview_spec.js index 4d88992a3e..c26ba7b528 100644 --- a/cms/static/js/spec/views/baseview_spec.js +++ b/cms/static/js/spec/views/baseview_spec.js @@ -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("ripe apples drop about my head"); + + 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"); + }); + }); }); }); diff --git a/cms/static/js/spec/views/unit_spec.js b/cms/static/js/spec/views/unit_spec.js index 498243f358..d912abc229 100644 --- a/cms/static/js/spec/views/unit_spec.js +++ b/cms/static/js/spec/views/unit_spec.js @@ -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 = + '
\ +
\ +

Unit Settings

\ +
\ +
\ +

\ + edit a draft \ +

\ +

\ + replace it with this draft \ +

\ +
\ +
\ +
\ +
'; + 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]); + }; + }); } ); diff --git a/cms/static/js/views/baseview.js b/cms/static/js/views/baseview.js index ad47bb86eb..8072705d36 100644 --- a/cms/static/js/views/baseview.js +++ b/cms/static/js/views/baseview.js @@ -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. diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index 0e5e83fbef..f217d7c979 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -136,11 +136,9 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", 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'); - } + $(".CodeMirror").css({"overflow": "none"}); + $(".modal-content").removeAttr("style"); + $cheatsheet.removeClass('shown'); this.selectMode(mode); }, diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 0aa5e662ec..8df685f5f0 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -195,6 +195,11 @@ .modal-window .editor-with-buttons { margin-bottom: ($baseline*3); + // temporary fix until xblock structure is set + &.wrapper-comp-settings .list-input.settings-list { + height: 375px; + } + // TODO: need to sync up (alongside general editing mode) with xblocks.scss UI .xblock-actions { background-color: $gray-l4; @@ -202,9 +207,6 @@ width: 100%; bottom: 0; } - - - } diff --git a/cms/templates/import.html b/cms/templates/import.html index 823dcbd36e..2c4fdd17d6 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -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); diff --git a/cms/urls.py b/cms/urls.py index b9c9e0e785..aeacee961a 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -140,3 +140,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), +) diff --git a/common/djangoapps/embargo/admin.py b/common/djangoapps/embargo/admin.py index 8ff4cc3970..b0d636514a 100644 --- a/common/djangoapps/embargo/admin.py +++ b/common/djangoapps/embargo/admin.py @@ -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 @@ -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. diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index fc6ea6e344..9d8ae8accd 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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 @@ -34,7 +33,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 @@ -718,7 +716,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) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 48d8bb642e..c28a54afe8 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -12,16 +12,17 @@ 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 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, @@ -146,12 +147,58 @@ class DashboardTest(TestCase): def setUp(self): self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG) self.assertIsNotNone(self.course) - self.user = UserFactory.create(username="jack", email="jack@fake.edx.org") + self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test') CourseModeFactory.create( course_id=self.course.id, mode_slug='honor', mode_display_name='Honor Code', ) + self.client = Client() + + def check_verification_status_on(self, mode, value): + """ + Check that the css class and the status message are in the dashboard html. + """ + CourseEnrollment.enroll(self.user, self.course.location.course_id, mode=mode) + try: + response = self.client.get(reverse('dashboard')) + except NoReverseMatch: + raise SkipTest("Skip this test if url cannot be found (ie running from CMS tests)") + self.assertContains(response, "class=\"course {0}\"".format(mode)) + self.assertContains(response, value) + + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': True}) + def test_verification_status_visible(self): + """ + Test that the certificate verification status for courses is visible on the dashboard. + """ + self.client.login(username="jack", password="test") + self.check_verification_status_on('verified', 'You\'re enrolled as a verified student') + self.check_verification_status_on('honor', 'You\'re enrolled as an honor code student') + self.check_verification_status_on('audit', 'You\'re auditing this course') + + def check_verification_status_off(self, mode, value): + """ + Check that the css class and the status message are not in the dashboard html. + """ + CourseEnrollment.enroll(self.user, self.course.location.course_id, mode=mode) + try: + response = self.client.get(reverse('dashboard')) + except NoReverseMatch: + raise SkipTest("Skip this test if url cannot be found (ie running from CMS tests)") + self.assertNotContains(response, "class=\"course {0}\"".format(mode)) + self.assertNotContains(response, value) + + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': False}) + def test_verification_status_invisible(self): + """ + Test that the certificate verification status for courses is not visible on the dashboard + if the verified certificates setting is off. + """ + self.client.login(username="jack", password="test") + self.check_verification_status_off('verified', 'You\'re enrolled as a verified student') + self.check_verification_status_off('honor', 'You\'re enrolled as an honor code student') + self.check_verification_status_off('audit', 'You\'re auditing this course') def test_course_mode_info(self): verified_mode = CourseModeFactory.create( @@ -192,15 +239,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 = "edX/Test101/2013" @@ -254,13 +296,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_id): """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_id, @@ -268,12 +309,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_id): """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_id, @@ -281,7 +321,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) @@ -445,8 +485,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 diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 34c30c4503..6e02a2a29b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -200,7 +200,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 +291,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 +311,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, diff --git a/common/djangoapps/terrain/stubs/lti.py b/common/djangoapps/terrain/stubs/lti.py index d2ca7998bc..87ed5ef214 100644 --- a/common/djangoapps/terrain/stubs/lti.py +++ b/common/djangoapps/terrain/stubs/lti.py @@ -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:
' + 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 {}
'.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 {}
'.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(""" -
+
- """).format(submit_url) +
+ +
+
+ +
+ """).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): """ diff --git a/common/djangoapps/terrain/stubs/tests/test_lti_stub.py b/common/djangoapps/terrain/stubs/tests/test_lti_stub.py index 40a5bf37b1..0ea2e2dcd6 100644 --- a/common/djangoapps/terrain/stubs/tests/test_lti_stub.py +++ b/common/djangoapps/terrain/stubs/tests/test_lti_stub.py @@ -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()) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 3da391c729..241a957483 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -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 diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index d5e2e93ded..c00290a28e 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -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() diff --git a/common/djangoapps/track/middleware.py b/common/djangoapps/track/middleware.py index 54934c5f36..e32ee9d4ac 100644 --- a/common/djangoapps/track/middleware.py +++ b/common/djangoapps/track/middleware.py @@ -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 diff --git a/common/djangoapps/track/shim.py b/common/djangoapps/track/shim.py new file mode 100644 index 0000000000..a0849f962b --- /dev/null +++ b/common/djangoapps/track/shim.py @@ -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 diff --git a/common/djangoapps/track/tests/test_middleware.py b/common/djangoapps/track/tests/test_middleware.py index 78d5d46b61..1f4dd3b499 100644 --- a/common/djangoapps/track/tests/test_middleware.py +++ b/common/djangoapps/track/tests/test_middleware.py @@ -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, + }) diff --git a/common/djangoapps/track/tests/test_shim.py b/common/djangoapps/track/tests/test_shim.py new file mode 100644 index 0000000000..b20c513d29 --- /dev/null +++ b/common/djangoapps/track/tests/test_shim.py @@ -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] diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 77d590777f..9462907ff6 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -165,7 +165,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.course_id) == MONGO_MODULESTORE_TYPE is_studio_course = block.course_edit_method == "Studio" diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index f66abf9134..13627ae0f6 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -62,7 +62,47 @@ log = logging.getLogger(__name__) ######################################################################### -registry = TagRegistry() +registry = TagRegistry() # pylint: disable=C0103 + + +class Status(object): + """ + Problem status + attributes: classname, display_name + """ + css_classes = { + # status: css class + 'unsubmitted': 'unanswered', + 'incomplete': 'incorrect', + 'queued': 'processing', + } + __slots__ = ('classname', '_status', 'display_name') + + def __init__(self, status, gettext_func=unicode): + self.classname = self.css_classes.get(status, status) + _ = gettext_func + names = { + 'correct': _('correct'), + 'incorrect': _('incorrect'), + 'incomplete': _('incomplete'), + 'unanswered': _('unanswered'), + 'unsubmitted': _('unanswered'), + 'queued': _('processing'), + } + self.display_name = names.get(status, unicode(status)) + self._status = status or '' + + def __str__(self): + return self._status + + def __unicode__(self): + return self._status.decode('utf8') + + def __repr__(self): + return 'Status(%r)' % self._status + + def __eq__(self, other): + return self._status == str(other) class Attribute(object): @@ -261,9 +301,7 @@ class InputTypeBase(object): context = { 'id': self.input_id, 'value': self.value, - 'status': self.status, - 'status_class': self.status_class, - 'status_display': self.status_display, + 'status': Status(self.status, self.capa_system.i18n.ugettext), 'msg': self.msg, 'STATIC_URL': self.capa_system.STATIC_URL, } @@ -273,34 +311,6 @@ class InputTypeBase(object): context.update(self._extra_context()) return context - @property - def status_class(self): - """ - Return the CSS class for the associated status. - """ - statuses = { - 'unsubmitted': 'unanswered', - 'incomplete': 'incorrect', - 'queued': 'processing', - } - return statuses.get(self.status, self.status) - - @property - def status_display(self): - """ - Return the human-readable and translated word for the associated status. - """ - _ = self.capa_system.i18n.ugettext - statuses = { - 'correct': _('correct'), - 'incorrect': _('incorrect'), - 'incomplete': _('incomplete'), - 'unanswered': _('unanswered'), - 'unsubmitted': _('unanswered'), - 'queued': _('queued'), - } - return statuses.get(self.status, self.status) - def _extra_context(self): """ Subclasses can override this to return extra context that should be passed to their templates for rendering. diff --git a/common/lib/capa/capa/templates/annotationinput.html b/common/lib/capa/capa/templates/annotationinput.html index 145a7c2cad..3502f9ff25 100644 --- a/common/lib/capa/capa/templates/annotationinput.html +++ b/common/lib/capa/capa/templates/annotationinput.html @@ -52,13 +52,7 @@ % endif - % if status == 'unsubmitted': - Status: Unanswered - % elif status == 'incomplete': - Status: Incorrect - % elif status == 'incorrect' and not has_options_value: - Status: Incorrect - % endif + ${status.display_name}

diff --git a/common/lib/capa/capa/templates/chemicalequationinput.html b/common/lib/capa/capa/templates/chemicalequationinput.html index d6a2e36a9a..2ae29406b6 100644 --- a/common/lib/capa/capa/templates/chemicalequationinput.html +++ b/common/lib/capa/capa/templates/chemicalequationinput.html @@ -1,7 +1,7 @@
-
+
${value|h} - - ${status_display} + ${status.display_name}

diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html index 60145c3a68..814fae6594 100644 --- a/common/lib/capa/capa/templates/choicegroup.html +++ b/common/lib/capa/capa/templates/choicegroup.html @@ -1,7 +1,7 @@
% if input_type == 'checkbox' or not value: - @@ -11,7 +11,7 @@ %endif %endfor - - ${status_display} + ${status.display_name} % endif @@ -51,7 +51,7 @@ % if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ): % if status in ('correct', 'incorrect') and not show_correctness=='never': - ${choice_description|h} - ${status_display} + ${choice_description|h} - ${status.display_name} % endif % endif diff --git a/common/lib/capa/capa/templates/choicetext.html b/common/lib/capa/capa/templates/choicetext.html index 2efde27088..6991ede721 100644 --- a/common/lib/capa/capa/templates/choicetext.html +++ b/common/lib/capa/capa/templates/choicetext.html @@ -10,7 +10,7 @@
% if input_type == 'checkbox' or not element_checked: - + % endif
diff --git a/common/lib/capa/capa/templates/codeinput.html b/common/lib/capa/capa/templates/codeinput.html index f213099e6b..dda2d6b97b 100644 --- a/common/lib/capa/capa/templates/codeinput.html +++ b/common/lib/capa/capa/templates/codeinput.html @@ -17,10 +17,10 @@
- ${status_display} + ${status.display_name} % if status == 'queued': @@ -30,7 +30,7 @@
% endif -

${status_display}

+

${status.display_name}

diff --git a/common/lib/capa/capa/templates/crystallography.html b/common/lib/capa/capa/templates/crystallography.html index 97dbba4f0a..df2129344c 100644 --- a/common/lib/capa/capa/templates/crystallography.html +++ b/common/lib/capa/capa/templates/crystallography.html @@ -9,29 +9,14 @@
- % if status == 'unsubmitted': -
- % elif status == 'correct': -
- % elif status == 'incorrect': -
- % elif status == 'incomplete': -
- % endif + % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +

- % if status == 'unsubmitted': - unanswered - % elif status == 'correct': - correct - % elif status == 'incorrect': - incorrect - % elif status == 'incomplete': - incomplete - % endif + ${status.display_name}

diff --git a/common/lib/capa/capa/templates/designprotein2dinput.html b/common/lib/capa/capa/templates/designprotein2dinput.html index d44d853661..066d796db3 100644 --- a/common/lib/capa/capa/templates/designprotein2dinput.html +++ b/common/lib/capa/capa/templates/designprotein2dinput.html @@ -2,14 +2,8 @@
- % if status == 'unsubmitted': -
- % elif status == 'correct': -
- % elif status == 'incorrect': -
- % elif status == 'incomplete': -
+ % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
% endif
@@ -17,15 +11,7 @@

- % if status == 'unsubmitted': - unanswered - % elif status == 'correct': - correct - % elif status == 'incorrect': - incorrect - % elif status == 'incomplete': - incomplete - % endif + ${status.display_name}

diff --git a/common/lib/capa/capa/templates/drag_and_drop_input.html b/common/lib/capa/capa/templates/drag_and_drop_input.html index 8b8feb993c..589f9c4f57 100644 --- a/common/lib/capa/capa/templates/drag_and_drop_input.html +++ b/common/lib/capa/capa/templates/drag_and_drop_input.html @@ -8,14 +8,8 @@
- % if status == 'unsubmitted': -
- % elif status == 'correct': -
- % elif status == 'incorrect': -
- % elif status == 'incomplete': -
+ % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
% endif @@ -23,15 +17,7 @@ style="display:none;"/>

- % if status == 'unsubmitted': - unanswered - % elif status == 'correct': - correct - % elif status == 'incorrect': - incorrect - % elif status == 'incomplete': - incomplete - % endif + ${status.display_name}

diff --git a/common/lib/capa/capa/templates/editageneinput.html b/common/lib/capa/capa/templates/editageneinput.html index dd389987d4..4ee3cc5d7e 100644 --- a/common/lib/capa/capa/templates/editageneinput.html +++ b/common/lib/capa/capa/templates/editageneinput.html @@ -2,14 +2,8 @@
- % if status == 'unsubmitted': -
- % elif status == 'correct': -
- % elif status == 'incorrect': -
- % elif status == 'incomplete': -
+ % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
% endif
@@ -18,15 +12,7 @@

- % if status == 'unsubmitted': - unanswered - % elif status == 'correct': - correct - % elif status == 'incorrect': - incorrect - % elif status == 'incomplete': - incomplete - % endif + ${status.display_name}

diff --git a/common/lib/capa/capa/templates/editamolecule.html b/common/lib/capa/capa/templates/editamolecule.html index 235e30099e..e5131b21d9 100644 --- a/common/lib/capa/capa/templates/editamolecule.html +++ b/common/lib/capa/capa/templates/editamolecule.html @@ -1,15 +1,9 @@
- % if status == 'unsubmitted': -
- % elif status == 'correct': -
- % elif status == 'incorrect': -
- % elif status == 'incomplete': -
- % endif + % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
+ % endif
@@ -23,15 +17,7 @@

- % if status == 'unsubmitted': - unanswered - % elif status == 'correct': - correct - % elif status == 'incorrect': - incorrect - % elif status == 'incomplete': - incomplete - % endif + ${status.display_name}



diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html index 374f9734a8..17209b8564 100644 --- a/common/lib/capa/capa/templates/filesubmission.html +++ b/common/lib/capa/capa/templates/filesubmission.html @@ -1,14 +1,9 @@
- % if status == 'unsubmitted': - Unanswered - % elif status == 'correct': - Correct - % elif status == 'incorrect': - Incorrect - % elif status == 'queued': - Queued - + + ${status.display_name} + % if status == 'queued': + % endif

${status}

diff --git a/common/lib/capa/capa/templates/formulaequationinput.html b/common/lib/capa/capa/templates/formulaequationinput.html index f5d9821232..10b3b22ea5 100644 --- a/common/lib/capa/capa/templates/formulaequationinput.html +++ b/common/lib/capa/capa/templates/formulaequationinput.html @@ -1,6 +1,6 @@ <% doinline = 'style="display:inline-block;vertical-align:top"' if inline else "" %>
-
+
- ${status_display} + - ${status.display_name}

diff --git a/common/lib/capa/capa/templates/imageinput.html b/common/lib/capa/capa/templates/imageinput.html index 25da119651..38dd42ba8d 100644 --- a/common/lib/capa/capa/templates/imageinput.html +++ b/common/lib/capa/capa/templates/imageinput.html @@ -39,38 +39,11 @@ (new ImageInput('${id}')); - % if status == 'unsubmitted': - Status: unanswered + ${status.display_name} - % elif status == 'correct': - - Status: correct - - % elif status == 'incorrect': - - Status: incorrect - - % elif status == 'incomplete': - - Status: incorrect - - % endif
diff --git a/common/lib/capa/capa/templates/jsinput.html b/common/lib/capa/capa/templates/jsinput.html index 6ad0ea7b77..b45dbda431 100644 --- a/common/lib/capa/capa/templates/jsinput.html +++ b/common/lib/capa/capa/templates/jsinput.html @@ -17,14 +17,8 @@
- % if status == 'unsubmitted': -
- % elif status == 'correct': -
- % elif status == 'incorrect': -
- % elif status == 'incomplete': -
+ % if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: +
% endif

+ +5. Replace the default URL in the **src** attribute (**https://studio.edx.org/c4x/edX/DemoX/asset/eulerLineDemo.html**) with the URL of the page that contains the exercise or tool. **This URL must start with https**. Make sure you don't delete the quotation marks that surround the URL. + +#. Change the attributes in the IFrame element to specify any other settings that you want. For more information about these settings, see :ref:`IFrame Settings`. You can also change the text between the opening and closing ``

+ +.. image:: /Images/IFrame_3.png + :alt: IFrame with only top half showing and vertical scroll bar on the side + :width: 500 + +.. code-block:: html + +

+ +.. image:: /Images/IFrame_4.png + :alt: + :width: 500 + +For more information about IFrame attributes, see the `IFrame specification `_. diff --git a/docs/en_us/course_authors/source/exercises_tools/index.rst b/docs/en_us/course_authors/source/exercises_tools/index.rst index 9f9b1a835f..40fca14fea 100644 --- a/docs/en_us/course_authors/source/exercises_tools/index.rst +++ b/docs/en_us/course_authors/source/exercises_tools/index.rst @@ -20,6 +20,7 @@ Creating Exercises and Tools full_screen_image gene_explorer google_hangouts + iframe image_mapped_input lti_component math_expression_input diff --git a/docs/en_us/data/README b/docs/en_us/data/README deleted file mode 100644 index ad940ee8f1..0000000000 --- a/docs/en_us/data/README +++ /dev/null @@ -1,3 +0,0 @@ -This directory contains some high level documentation for the code. - -WARNING: much of this is out-of-date. It still may be helpful, though. diff --git a/docs/en_us/data/source/conf.py b/docs/en_us/data/source/conf.py index f94142da74..18e89e3ac8 100644 --- a/docs/en_us/data/source/conf.py +++ b/docs/en_us/data/source/conf.py @@ -35,7 +35,7 @@ master_doc = 'index' # so a file named "default.css" will overwrite the builtin "default.css". #html_static_path.append('source/_static') -project = u'edX Data Documentation' +project = u'edX Research Guide' copyright = u'2014, edX' # The short X.Y version. diff --git a/docs/en_us/data/source/course_data_formats/conditional_module/conditional_module.rst b/docs/en_us/data/source/course_data_formats/conditional_module/conditional_module.rst deleted file mode 100644 index b5ec2063a8..0000000000 --- a/docs/en_us/data/source/course_data_formats/conditional_module/conditional_module.rst +++ /dev/null @@ -1,80 +0,0 @@ -********************************************** -Xml format of conditional module [xmodule] -********************************************** - -.. module:: conditional_module - -Format description -================== - -The main tag of Conditional module input is: - -.. code-block:: xml - - ... - -``conditional`` can include any number of any xmodule tags (``html``, ``video``, ``poll``, etc.) or ``show`` tags. - -conditional tag ---------------- - -The main container for a single instance of Conditional module. The following attributes can -be specified for this tag:: - - sources - location id of required modules, separated by ';' - [message | ""] - message for case, where one or more are not passed. Here you can use variable {link}, which generate link to required module. - - [submitted] - map to `is_submitted` module method. - (pressing RESET button makes this function to return False.) - - [correct] - map to `is_correct` module method - [attempted] - map to `is_attempted` module method - [poll_answer] - map to `poll_answer` module attribute - [voted] - map to `voted` module attribute - -show tag --------- - -Symlink to some set of xmodules. The following attributes can -be specified for this tag:: - - sources - location id of modules, separated by ';' - -Example -======= - -Examples of conditional depends on poll -------------------------------------------- - -.. code-block:: xml - - - -

You see this, cause your vote value for "First question" was "man"

- -
- -Examples of conditional depends on poll (use tag) --------------------------------------------------------- - -.. code-block:: xml - - - - - - - -Examples of conditional depends on problem -------------------------------------------- - -.. code-block:: xml - - - You see this, cause "lec27_Q1" is attempted. - - - You see this, cause "lec27_Q1" is not attempted. - diff --git a/docs/en_us/data/source/course_data_formats/custom_response.rst b/docs/en_us/data/source/course_data_formats/custom_response.rst deleted file mode 100644 index b59e671f41..0000000000 --- a/docs/en_us/data/source/course_data_formats/custom_response.rst +++ /dev/null @@ -1,142 +0,0 @@ -#################################### -CustomResponse XML and Python Script -#################################### - -This document explains how to write a CustomResponse problem. CustomResponse -problems execute Python script to check student answers and provide hints. - -There are two general ways to create a CustomResponse problem: - - -***************** -Answer tag format -***************** -One format puts the Python code in an ```` tag: - -.. code-block:: xml - - -

What is the sum of 2 and 3?

- - - - - - - # Python script goes here - -
- - -The Python script interacts with these variables in the global context: - * ``answers``: An ordered list of answers the student provided. - For example, if the student answered ``6``, then ``answers[0]`` would - equal ``6``. - * ``expect``: The value of the ``expect`` attribute of ```` - (if provided). - * ``correct``: An ordered list of strings indicating whether the - student answered the question correctly. Valid values are - ``"correct"``, ``"incorrect"``, and ``"unknown"``. You can set these - values in the script. - * ``messages``: An ordered list of message strings that will be displayed - beneath each input. You can use this to provide hints to users. - For example ``messages[0] = "The capital of California is Sacramento"`` - would display that message beneath the first input of the response. - * ``overall_message``: A string that will be displayed beneath the - entire problem. You can use this to provide a hint that applies - to the entire problem rather than a particular input. - -Example of a checking script: - -.. code-block:: python - - if answers[0] == expect: - correct[0] = 'correct' - overall_message = 'Good job!' - else: - correct[0] = 'incorrect' - messages[0] = 'This answer is incorrect' - overall_message = 'Please try again' - -**Important**: Python is picky about indentation. Within the ```` tag, -you must begin your script with no indentation. - -***************** -Script tag format -***************** -The other way to create a CustomResponse is to put a "checking function" -in a `` - - - -**Important**: Python is picky about indentation. Within the `` + (function() { + var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; + ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; + var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); + })(); + +% endif diff --git a/lms/templates/lti.html b/lms/templates/lti.html index 0018554528..91348a8a5e 100644 --- a/lms/templates/lti.html +++ b/lms/templates/lti.html @@ -1,17 +1,31 @@ <%! import json %> <%! from django.utils.translation import ugettext as _ %> +

+ ## Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS + ${display_name} (${_('External resource')}) +

+ +% if has_score and weight: +
+ % if module_score is not None: + ## Translators: "points" is the student's achieved score on this LTI unit, and "total_points" is the maximum number of points achievable. + (${_("{points} / {total_points} points").format(points=module_score, total_points=weight)}) + % else: + ## Translators: "total_points" is the maximum number of points achievable on this LTI unit + (${_("{total_points} points possible").format(total_points=weight)}) + % endif +
+% endif +
-% if launch_url and launch_url != 'http://www.example.com': +% if launch_url and launch_url != 'http://www.example.com' and not hide_launch: % if open_in_a_new_page: diff --git a/lms/templates/mktg_iframe.html b/lms/templates/mktg_iframe.html index e6c0f55acd..8ce5cc1f24 100644 --- a/lms/templates/mktg_iframe.html +++ b/lms/templates/mktg_iframe.html @@ -22,9 +22,9 @@ <%block name="headextra"/> - % if not course: - <%include file="google_analytics.html" /> - % endif +