diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..c376115570b5ac032f3101aa53422248259b5ccf --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +### Python artifacts +*.pyc +*.egg-info + +### Editor and IDE artifacts +*~ +*.swp +*.orig +/nbproject +.idea/ +.redcar/ +codekit-config.json +.pycharm_helpers/ + +### Testing artifacts +.coverage +var/ \ No newline at end of file diff --git a/.pep8 b/.pep8 new file mode 100644 index 0000000000000000000000000000000000000000..9029126014322c52d1a514a6ede6f6a37bf15395 --- /dev/null +++ b/.pep8 @@ -0,0 +1,4 @@ +[pep8] +ignore=E501 +max_line_length=120 +exclude=settings diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000000000000000000000000000000000..7832fda8000335c1d3242182b32de762f3f364eb --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: python +python: "2.7" +install: + - "make install" +sudo: false +script: + - make quality + - make test +branches: + only: + - master +after_success: coveralls diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..dba13ed2ddf783ee8118c6a581dbf75305f816a3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<http://www.gnu.org/licenses/>. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..8b5c07b9cfce12093d1d178361899a3619f66564 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +all: install compile-sass quality test + +install-test: + pip install -q -r test_requirements.txt + +install: install-test + +compile-sass: + ./scripts/sass.sh + +quality: + ./scripts/quality.sh + +test: + ./scripts/test.sh diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000000000000000000000000000000000..d1a27303b2a0b55bc3a5514280c31093907224c8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +Copyright (C) 2015 edX + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. diff --git a/README.md b/README.md deleted file mode 100644 index a795fdc0c1fcb20074b59feeddfef6e944b9162b..0000000000000000000000000000000000000000 --- a/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# LTI Consumer XBlock - -This is a Python package containing an implementation of an LTI consumer using the XBlock API. - diff --git a/README.rst b/README.rst new file mode 100644 index 0000000000000000000000000000000000000000..ca07818976aafde7f6575248feccbadcc3fc1b04 --- /dev/null +++ b/README.rst @@ -0,0 +1,81 @@ +LTI Consumer XBlock |Build Status| |Coveralls| +---------------------------------------------- + +This XBlock implements the consumer side of the LTI specification enabling +integration of third-party LTI provider tools. + +Installation +------------ + +Install the requirements into the python virtual environment of your +``edx-platform`` installation by running the following command from the +root folder: + +.. code:: bash + + $ pip install -r requirements.txt + +Enabling in Studio +------------------ + +You can enable the LTI Consumer XBlock in Studio through the +advanced settings. + +1. From the main page of a specific course, navigate to + ``Settings -> Advanced Settings`` from the top menu. +2. Check for the ``advanced_modules`` policy key, and add + ``"lti_consumer"`` to the policy value list. +3. Click the "Save changes" button. + +Workbench installation and settings +----------------------------------- + +Install to the workbench's virtualenv by running the following command +from the xblock-lti-consumer repo root with the workbench's virtualenv activated: + +.. code:: bash + + $ make install + +Running tests +------------- + +From the xblock-lti-consumer repo root, run the tests with the following command: + +.. code:: bash + + $ make test + +Running code quality check +-------------------------- + +From the xblock-lti-consumer repo root, run the quality checks with the following command: + +.. code:: bash + + $ make quality + +Compiling Sass +-------------- + +This XBlock uses Sass for writing style rules. The Sass is compiled +and committed to the git repo using: + +.. code:: bash + + $ make compile-sass + +Changes to style rules should be made to the Sass files, compiled to CSS, +and committed to the git repository. + +License +------- + +The LTI Consumer XBlock is available under the Apache Version 2.0 License. + + +.. |Build Status| image:: https://travis-ci.org/edx/xblock-lti-consumer.svg + :target: https://travis-ci.org/edx/xblock-lti-consumer + +.. |Coveralls| image:: https://coveralls.io/repos/edx/xblock-lti-consumer/badge.svg?branch=master&service=github + :target: https://coveralls.io/github/edx/xblock-lti-consumer?branch=master diff --git a/lti_consumer/__init__.py b/lti_consumer/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bdd1ff4c530841cdc7da9767f819899da181c0ca --- /dev/null +++ b/lti_consumer/__init__.py @@ -0,0 +1,4 @@ +""" +Runtime will load the XBlock class from here. +""" +from .lti_consumer import LtiConsumerXBlock diff --git a/lti_consumer/exceptions.py b/lti_consumer/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..16f3383ffd742e15d72fbe54f54f41fb2200cb1f --- /dev/null +++ b/lti_consumer/exceptions.py @@ -0,0 +1,10 @@ +""" +Exceptions for the LTI Consumer XBlock. +""" + + +class LtiError(Exception): + """ + General error class for LTI XBlock. + """ + pass diff --git a/lti_consumer/lti.py b/lti_consumer/lti.py new file mode 100644 index 0000000000000000000000000000000000000000..83bcd799f94bb5b399bf353d032dcacba158ee6a --- /dev/null +++ b/lti_consumer/lti.py @@ -0,0 +1,274 @@ +""" +This module encapsulates code which implements the LTI specification. + +For more details see: +https://www.imsglobal.org/activity/learning-tools-interoperability +""" + +import logging +import urllib +import json + +from .exceptions import LtiError +from .oauth import get_oauth_request_signature, verify_oauth_body_signature + + +log = logging.getLogger(__name__) + + +def parse_result_json(json_str): + """ + Helper method for verifying LTI 2.0 JSON object contained in the body of the request. + + The json_str must be loadable. It can either be an dict (object) or an array whose first element is an dict, + in which case that first dict is considered. + The dict must have the "@type" key with value equal to "Result", + "resultScore" key with value equal to a number [0, 1], if "resultScore" is not + included in the JSON body, score will be returned as None + The "@context" key must be present, but we don't do anything with it. And the "comment" key may be + present, in which case it must be a string. + + Arguments: + json_str (unicode): The body of the LTI 2.0 results service request, which is a JSON string + + Returns: + (float, str): (score, [optional]comment) if parsing is successful + + Raises: + LtiError: if verification fails + """ + try: + json_obj = json.loads(json_str) + except (ValueError, TypeError): + msg = "Supplied JSON string in request body could not be decoded: {}".format(json_str) + log.error("[LTI] %s", msg) + raise LtiError(msg) + + # The JSON object must be a dict. If a non-empty list is passed in, + # use the first element, but only if it is a dict + if isinstance(json_obj, list) and len(json_obj) >= 1: + json_obj = json_obj[0] + + if not isinstance(json_obj, dict): + msg = ("Supplied JSON string is a list that does not contain an object as the first element. {}" + .format(json_str)) + log.error("[LTI] %s", msg) + raise LtiError(msg) + + # '@type' must be "Result" + result_type = json_obj.get("@type") + if result_type != "Result": + msg = "JSON object does not contain correct @type attribute (should be 'Result', is z{})".format(result_type) + log.error("[LTI] %s", msg) + raise LtiError(msg) + + # '@context' must be present as a key + if '@context' not in json_obj: + msg = "JSON object does not contain required key @context" + log.error("[LTI] %s", msg) + raise LtiError(msg) + + # Return None if the resultScore key is missing, this condition + # will be handled by the upstream caller of this function + if "resultScore" not in json_obj: + score = None + else: + # if present, 'resultScore' must be a number between 0 and 1 inclusive + try: + score = float(json_obj.get('resultScore', "unconvertable")) # Check if float is present and the right type + if not 0.0 <= score <= 1.0: + msg = 'score value outside the permitted range of 0.0-1.0.' + log.error("[LTI] %s", msg) + raise LtiError(msg) + except (TypeError, ValueError) as err: + msg = "Could not convert resultScore to float: {}".format(err.message) + log.error("[LTI] %s", msg) + raise LtiError(msg) + + return score, json_obj.get('comment', "") + + +class LtiConsumer(object): + """ + Limited implementation of the LTI 1.1/2.0 specification. + + For the LTI 1.1 specification see: + https://www.imsglobal.org/specs/ltiv1p1 + + For the LTI 2.0 specification see: + https://www.imsglobal.org/specs/ltiv2p0 + """ + CONTENT_TYPE_RESULT_JSON = 'application/vnd.ims.lis.v2.result+json' + + def __init__(self, xblock): + self.xblock = xblock + + def get_signed_lti_parameters(self): + """ + Signs LTI launch request and returns signature and OAuth parameters. + + Arguments: + None + + Returns: + dict: LTI launch parameters + """ + + # Must have parameters for correct signing from LTI: + lti_parameters = { + u'user_id': self.xblock.user_id, + u'oauth_callback': u'about:blank', + u'launch_presentation_return_url': '', + u'lti_message_type': u'basic-lti-launch-request', + u'lti_version': 'LTI-1p0', + u'roles': self.xblock.role, + + # Parameters required for grading: + u'resource_link_id': self.xblock.resource_link_id, + u'lis_result_sourcedid': self.xblock.lis_result_sourcedid, + + u'context_id': self.xblock.context_id, + } + + if self.xblock.has_score: + lti_parameters.update({ + u'lis_outcome_service_url': self.xblock.outcome_service_url + }) + + self.xblock.user_email = "" + self.xblock.user_username = "" + + # Username and email can't be sent in studio mode, because the user object is not defined. + # To test functionality test in LMS + + if callable(self.xblock.runtime.get_real_user): + real_user_object = self.xblock.runtime.get_real_user(self.xblock.runtime.anonymous_student_id) + self.xblock.user_email = getattr(real_user_object, "email", "") + self.xblock.user_username = getattr(real_user_object, "username", "") + + if self.xblock.ask_to_send_username and self.xblock.user_username: + lti_parameters["lis_person_sourcedid"] = self.xblock.user_username + if self.xblock.ask_to_send_email and self.xblock.user_email: + lti_parameters["lis_person_contact_email_primary"] = self.xblock.user_email + + # Appending custom parameter for signing. + lti_parameters.update(self.xblock.prefixed_custom_parameters) + + headers = { + # This is needed for body encoding: + 'Content-Type': 'application/x-www-form-urlencoded', + } + + key, secret = self.xblock.lti_provider_key_secret + oauth_signature = get_oauth_request_signature(key, secret, self.xblock.launch_url, headers, lti_parameters) + + # Parse headers to pass to template as part of context: + oauth_signature = dict([param.strip().replace('"', '').split('=') for param in oauth_signature.split(',')]) + + oauth_signature[u'oauth_nonce'] = oauth_signature.pop(u'OAuth oauth_nonce') + + # oauthlib encodes signature with + # 'Content-Type': 'application/x-www-form-urlencoded' + # so '='' becomes '%3D'. + # We send form via browser, so browser will encode it again, + # So we need to decode signature back: + oauth_signature[u'oauth_signature'] = urllib.unquote(oauth_signature[u'oauth_signature']).decode('utf8') + + # Add LTI parameters to OAuth parameters for sending in form. + lti_parameters.update(oauth_signature) + return lti_parameters + + def get_result(self, user): # pylint: disable=unused-argument + """ + Helper request handler for GET requests to LTI 2.0 result endpoint + + GET handler for lti_2_0_result. Assumes all authorization has been checked. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object (unused) + real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix + + Returns: + webob.response: response to this request, in JSON format with status 200 if success + """ + self.xblock.runtime.rebind_noauth_module_to_user(self, user) + + response = { + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type": "Result" + } + if self.xblock.module_score is not None: + response['resultScore'] = round(self.xblock.module_score, 2) + response['comment'] = self.xblock.score_comment + + return response + + def delete_result(self, user): # pylint: disable=unused-argument + """ + Helper request handler for DELETE requests to LTI 2.0 result endpoint + + DELETE handler for lti_2_0_result. Assumes all authorization has been checked. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object (unused) + real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix + + Returns: + webob.response: response to this request. status 200 if success + """ + self.xblock.clear_user_module_score(user) + return {} + + def put_result(self, user, result_json): + """ + Helper request handler for PUT requests to LTI 2.0 result endpoint + + PUT handler for lti_2_0_result. Assumes all authorization has been checked. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object + real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix + + Returns: + webob.response: response to this request. status 200 if success. 404 if body of PUT request is malformed + """ + score, comment = parse_result_json(result_json) + + if score is None: + # According to http://www.imsglobal.org/lti/ltiv2p0/ltiIMGv2p0.html#_Toc361225514 + # PUTting a JSON object with no "resultScore" field is equivalent to a DELETE. + self.xblock.clear_user_module_score(user) + else: + self.xblock.set_user_module_score(user, score, self.xblock.max_score(), comment) + + return {} + + def verify_result_headers(self, request, verify_content_type=True): + """ + Helper method to validate LTI 2.0 REST result service HTTP headers. returns if correct, else raises LtiError + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object + verify_content_type (bool): If true, verifies the content type of the request is that spec'ed by LTI 2.0 + + Returns: + nothing, but will only return if verification succeeds + + Raises: + LtiError if verification fails + """ + content_type = request.headers.get('Content-Type') + if verify_content_type and content_type != LtiConsumer.CONTENT_TYPE_RESULT_JSON: + log.error("[LTI]: v2.0 result service -- bad Content-Type: %s", content_type) + raise LtiError( + "For LTI 2.0 result service, Content-Type must be %s. Got %s", + LtiConsumer.CONTENT_TYPE_RESULT_JSON, + content_type + ) + + __, secret = self.xblock.lti_provider_key_secret + try: + return verify_oauth_body_signature(request, secret, self.xblock.outcome_service_url) + except (ValueError, LtiError) as err: + log.error("[LTI]: v2.0 result service -- OAuth body verification failed: %s", err.message) + raise LtiError(err.message) diff --git a/lti_consumer/lti_consumer.py b/lti_consumer/lti_consumer.py new file mode 100644 index 0000000000000000000000000000000000000000..f3e4e8a071d7fc678181919e2c008a9a74983f65 --- /dev/null +++ b/lti_consumer/lti_consumer.py @@ -0,0 +1,839 @@ +""" +XBlock implementation of the LTI (Learning Tools Interoperability) consumer specification. + +Resources +--------- + +Background and detailed LTI specification can be found at: + + http://www.imsglobal.org/specs/ltiv1p1p1/implementation-guide + +This module is based on the version 1.1.1 of the LTI specification by the +IMS Global authority. For authentication, it uses OAuth1. + +When responding back to the LTI tool provider, we must issue a correct +response. Types of responses and their message payload is available at: + + Table A1.2 Interpretation of the 'CodeMajor/severity' matrix. + http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html + +A resource to test the LTI protocol (PHP realization): + + http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php + +We have also begun to add support for LTI 1.2/2.0. We will keep this +docstring in synch with what support is available. The first LTI 2.0 +feature to be supported is the REST API results service, see specification +at +http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html + +What is supported: +------------------ + +1.) Display of simple LTI in iframe or a new window. +2.) Multiple LTI components on a single page. +3.) The use of multiple LTI providers per course. +4.) Use of advanced LTI component that provides back a grade. + A) LTI 1.1.1 XML endpoint + a.) The LTI provider sends back a grade to a specified URL. + b.) Currently only action "update" is supported. "Read", and "delete" + actions initially weren't required. + B) LTI 2.0 Result Service JSON REST endpoint + (http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html) + a.) Discovery of all such LTI http endpoints for a course. External tools GET from this discovery + endpoint and receive URLs for interacting with individual grading units. + (see lms/djangoapps/courseware/views.py:get_course_lti_endpoints) + b.) GET, PUT and DELETE in LTI Result JSON binding + (http://www.imsglobal.org/lti/ltiv2p0/mediatype/application/vnd/ims/lis/v2/result+json/index.html) + for a provider to synchronize grades into edx-platform. Reading, Setting, and Deleteing + Numeric grades between 0 and 1 and text + basic HTML feedback comments are supported, via + GET / PUT / DELETE HTTP methods respectively +""" + +import logging +import bleach +import re +import json +import urllib + +from datetime import datetime +from collections import namedtuple +from webob import Response + +from xblock.core import String, Scope, List, XBlock +from xblock.fields import Boolean, Float, Integer +from xblock.fragment import Fragment + +from xblockutils.resources import ResourceLoader +from xblockutils.studio_editable import StudioEditableXBlockMixin + +from .exceptions import LtiError +from .oauth import log_authorization_header +from .lti import LtiConsumer +from .outcomes import OutcomeService + + +log = logging.getLogger(__name__) + +DOCS_ANCHOR_TAG_OPEN = ( + "<a " + "target='_blank' " + "href='" + "http://edx.readthedocs.org" + "/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/lti_component.html" + "'>" +) +RESULT_SERVICE_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>\w+)", re.UNICODE) +ROLE_MAP = { + 'student': u'Student', + 'staff': u'Administrator', + 'instructor': u'Instructor', +} +LTI_PARAMETERS = [ + 'lti_message_type', + 'lti_version', + 'resource_link_title', + 'resource_link_description', + 'user_image', + 'lis_person_name_given', + 'lis_person_name_family', + 'lis_person_name_full', + 'lis_person_contact_email_primary', + 'lis_person_sourcedid', + 'role_scope_mentor', + 'context_type', + 'context_title', + 'context_label', + 'launch_presentation_locale', + 'launch_presentation_document_target', + 'launch_presentation_css_url', + 'launch_presentation_width', + 'launch_presentation_height', + 'launch_presentation_return_url', + 'tool_consumer_info_product_family_code', + 'tool_consumer_info_version', + 'tool_consumer_instance_guid', + 'tool_consumer_instance_name', + 'tool_consumer_instance_description', + 'tool_consumer_instance_url', + 'tool_consumer_instance_contact_email', +] + + +def parse_handler_suffix(suffix): + """ + Parser function for HTTP request path suffixes + + parses the suffix argument (the trailing parts of the URL) of the LTI2.0 REST handler. + must be of the form "user/<anon_id>". Returns anon_id if match found, otherwise raises LtiError + + Arguments: + suffix (unicode): suffix to parse + + Returns: + unicode: anon_id if match found + + Raises: + LtiError if suffix cannot be parsed or is not in its expected form + """ + if suffix: + match_obj = RESULT_SERVICE_SUFFIX_PARSER.match(suffix) + if match_obj: + return match_obj.group('anon_id') + # fall-through handles all error cases + msg = "No valid user id found in endpoint URL" + log.info("[LTI]: %s", msg) + raise LtiError(msg) + + +LaunchTargetOption = namedtuple('LaunchTargetOption', ['display_name', 'value']) + + +class LaunchTarget(object): + """ + Constants for launch_target field options + """ + IFRAME = LaunchTargetOption('Inline', 'iframe') + MODAL = LaunchTargetOption('Modal', 'modal') + NEW_WINDOW = LaunchTargetOption('New Window', 'new_window') + + +@XBlock.needs('i18n') +class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): + """ + This XBlock provides an LTI consumer interface for integrating + third-party tools using the LTI specification. + + Except usual Xmodule structure it proceeds with OAuth signing. + How it works:: + + 1. Get credentials from course settings. + + 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: + + user_id + oauth_callback + lis_outcome_service_url + lis_result_sourcedid + launch_presentation_return_url + lti_message_type + lti_version + roles + *+ all custom parameters* + + These parameters should be encoded and signed by *OAuth1* together with + `launch_url` and *POST* request type. + + 3. Signing proceeds with client key/secret pair obtained from course settings. + That pair should be obtained from LTI provider and set into course settings by course author. + After that signature and other OAuth data are generated. + + OAuth data which is generated after signing is usual:: + + oauth_callback + oauth_nonce + oauth_consumer_key + oauth_signature_method + oauth_timestamp + oauth_version + + + 4. All that data is passed to form and sent to LTI provider server by browser via + autosubmit via JavaScript. + + Form example:: + + <form + action="${launch_url}" + name="ltiLaunchForm-${element_id}" + class="ltiLaunchForm" + method="post" + target="ltiLaunchFrame-${element_id}" + encType="application/x-www-form-urlencoded" + > + <input name="launch_presentation_return_url" value="" /> + <input name="lis_outcome_service_url" value="" /> + <input name="lis_result_sourcedid" value="" /> + <input name="lti_message_type" value="basic-lti-launch-request" /> + <input name="lti_version" value="LTI-1p0" /> + <input name="oauth_callback" value="about:blank" /> + <input name="oauth_consumer_key" value="${oauth_consumer_key}" /> + <input name="oauth_nonce" value="${oauth_nonce}" /> + <input name="oauth_signature_method" value="HMAC-SHA1" /> + <input name="oauth_timestamp" value="${oauth_timestamp}" /> + <input name="oauth_version" value="1.0" /> + <input name="user_id" value="${user_id}" /> + <input name="role" value="student" /> + <input name="oauth_signature" value="${oauth_signature}" /> + + <input name="custom_1" value="${custom_param_1_value}" /> + <input name="custom_2" value="${custom_param_2_value}" /> + <input name="custom_..." value="${custom_param_..._value}" /> + + <input type="submit" value="Press to Launch" /> + </form> + + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. + + If signatures are correct, LTI provider redirects iframe source to LTI tool web page, + and LTI tool is rendered to iframe inside course. + + Otherwise error message from LTI provider is generated. + """ + + display_name = String( + display_name="Display Name", + help=( + "Enter the name that students see for this component. " + "Analytics reports may also use the display name to identify this component." + ), + scope=Scope.settings, + default="LTI Consumer", + ) + description = String( + display_name="LTI Application Information", + help=( + "Enter a description of the third party application. " + "If requesting username and/or email, use this text box to inform users " + "why their username and/or email will be forwarded to a third party application." + ), + default="", + scope=Scope.settings + ) + lti_id = String( + display_name="LTI ID", + help=( + "Enter the LTI ID for the external LTI provider. " + "This value must be the same LTI ID that you entered in the " + "LTI Passports setting on the Advanced Settings page." + "<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting." + ).format( + docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, + anchor_close="</a>" + ), + default='', + scope=Scope.settings + ) + launch_url = String( + display_name="LTI URL", + help=( + "Enter the URL of the external tool that this component launches. " + "This setting is only used when Hide External Tool is set to False." + "<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting." + ).format( + docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, + anchor_close="</a>" + ), + default='', + scope=Scope.settings + ) + custom_parameters = List( + display_name="Custom Parameters", + help=( + "Add the key/value pair for any custom parameters, such as the page your e-book should open to or " + "the background color for this component. Ex. [\"page=1\", \"color=white\"]" + "<br />See the {docs_anchor_open}edX LTI documentation{anchor_close} for more details on this setting." + ).format( + docs_anchor_open=DOCS_ANCHOR_TAG_OPEN, + anchor_close="</a>" + ), + scope=Scope.settings + ) + launch_target = String( + display_name="LTI Launch Target", + help=( + "Select Inline if you want the LTI content to open in an IFrame in the current page. " + "Select Modal if you want the LTI content to open in a modal window in the current page. " + "Select New Window if you want the LTI content to open in a new browser window. " + "This setting is only used when Hide External Tool is set to False." + ), + default=LaunchTarget.IFRAME.value, + scope=Scope.settings, + values=[ + {"display_name": LaunchTarget.IFRAME.display_name, "value": LaunchTarget.IFRAME.value}, + {"display_name": LaunchTarget.MODAL.display_name, "value": LaunchTarget.MODAL.value}, + {"display_name": LaunchTarget.NEW_WINDOW.display_name, "value": LaunchTarget.NEW_WINDOW.value}, + ], + ) + button_text = String( + display_name="Button Text", + help=( + "Enter the text on the button used to launch the third party application. " + "This setting is only used when Hide External Tool is set to False and " + "LTI Launch Target is set to Modal or New Window." + ), + default="", + scope=Scope.settings + ) + inline_height = Integer( + display_name="Inline Height", + help=( + "Enter the desired pixel height of the iframe which will contain the LTI tool. " + "This setting is only used when Hide External Tool is set to False and " + "LTI Launch Target is set to Inline." + ), + default=800, + scope=Scope.settings + ) + modal_height = Integer( + display_name="Modal Height", + help=( + "Enter the desired pixel height of the modal overlay which will contain the LTI tool. " + "This setting is only used when Hide External Tool is set to False and " + "LTI Launch Target is set to Modal." + ), + default=405, + scope=Scope.settings + ) + modal_width = Integer( + display_name="Modal Width", + help=( + "Enter the desired pixel width of the modal overlay which will contain the LTI tool. " + "This setting is only used when Hide External Tool is set to False and " + "LTI Launch Target is set to Modal." + ), + default=720, + scope=Scope.settings + ) + has_score = Boolean( + display_name="Scored", + help="Select True if this component will receive a numerical score from the external LTI system.", + default=False, + scope=Scope.settings + ) + weight = Float( + display_name="Weight", + help=( + "Enter the number of points possible for this component. " + "The default value is 1.0. " + "This setting is only used when Scored is set to True." + ), + default=1.0, + scope=Scope.settings, + values={"min": 0}, + ) + module_score = Float( + help="The score kept in the xblock KVS -- duplicate of the published score in django DB", + default=None, + scope=Scope.user_state + ) + score_comment = String( + help="Comment as returned from grader, LTI2.0 spec", + default="", + scope=Scope.user_state + ) + hide_launch = Boolean( + display_name="Hide External Tool", + help=( + "Select True if you want to use this component as a placeholder for syncing with an external grading " + "system rather than launch an external tool. " + "This setting hides the Launch button and any IFrames for this component." + ), + default=False, + scope=Scope.settings + ) + accept_grades_past_due = Boolean( + display_name="Accept grades past deadline", + help="Select True to allow third party systems to post grades past the deadline.", + default=True, + scope=Scope.settings + ) + # Users will be presented with a message indicating that their e-mail/username would be sent to a third + # party application. When "Open in New Page" is not selected, the tool automatically appears without any + # user action. + ask_to_send_username = Boolean( + display_name="Request user's username", + # Translators: This is used to request the user's username for a third party service. + help="Select True to request the user's username.", + default=False, + scope=Scope.settings + ) + ask_to_send_email = Boolean( + display_name="Request user's email", + # Translators: This is used to request the user's email for a third party service. + help="Select True to request the user's email address.", + default=False, + scope=Scope.settings + ) + + # StudioEditableXBlockMixin configuration of fields editable in Studio + editable_fields = ( + 'display_name', 'description', 'lti_id', 'launch_url', 'custom_parameters', 'launch_target', 'button_text', + 'inline_height', 'modal_height', 'modal_width', 'has_score', 'weight', 'hide_launch', 'accept_grades_past_due', + 'ask_to_send_username', 'ask_to_send_email' + ) + + @property + def descriptor(self): + """ + Returns this XBlock object. + + This is for backwards compatibility with the XModule API. + Some LMS code still assumes a descriptor attribute on the XBlock object. + See courseware.module_render.rebind_noauth_module_to_user. + """ + return self + + @property + def context_id(self): + """ + Return context_id. + + context_id is an opaque identifier that uniquely identifies the context (e.g., a course) + that contains the link being launched. + """ + return unicode(self.course_id) # pylint: disable=no-member + + @property + def role(self): + """ + Get system user role and convert it to LTI role. + """ + return ROLE_MAP.get(self.runtime.get_user_role(), u'Student') + + @property + def course(self): + """ + Return course by course id. + """ + return self.runtime.descriptor_runtime.modulestore.get_course(self.course_id) # pylint: disable=no-member + + @property + def lti_provider_key_secret(self): + """ + Obtains client_key and client_secret credentials from current course. + """ + for lti_passport in self.course.lti_passports: + try: + lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] + except ValueError: + _ = self.runtime.service(self, "i18n").ugettext + msg = _('Could not parse LTI passport: {lti_passport}. Should be "id:key:secret" string.').format( + lti_passport='{0!r}'.format(lti_passport) + ) + raise LtiError(msg) + + if lti_id == self.lti_id.strip(): + return key, secret + + return '', '' + + @property + def user_id(self): + """ + Returns the opaque anonymous_student_id for the current user. + """ + user_id = self.runtime.anonymous_student_id + if user_id is None: + raise LtiError("Could not get user id for current request") + return unicode(urllib.quote(user_id)) + + @property + def resource_link_id(self): + """ + This is an opaque unique identifier that the LTI Tool Consumer guarantees will be unique + within the Tool Consumer for every placement of the link. + + If the tool / activity is placed multiple times in the same context, + each of those placements will be distinct. + + This value will also change if the item is exported from one system or + context and imported into another system or context. + + resource_link_id is a required LTI launch parameter. + + Example: u'edx.org-i4x-2-3-lti-31de800015cf4afb973356dbe81496df' + + Hostname, edx.org, + makes resource_link_id change on import to another system. + + Last part of location, location.name - 31de800015cf4afb973356dbe81496df, + is random hash, updated by course_id, + this makes resource_link_id unique inside single course. + + First part of location is tag-org-course-category, i4x-2-3-lti. + + Location.name itself does not change on import to another course, + but org and course_id change. + + So together with org and course_id in a form of + i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id: + makes resource_link_id to be unique among courses inside same system. + """ + return unicode(urllib.quote( + "{}-{}".format(self.runtime.hostname, self.location.html_id()) # pylint: disable=no-member + )) + + @property + def lis_result_sourcedid(self): + """ + This field contains an identifier that indicates the LIS Result Identifier (if any) + associated with this launch. This field identifies a unique row and column within the + TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id. + This value may change for a particular resource_link_id / user_id from one launch to the next. + The TP should only retain the most recent value for this field for a particular resource_link_id / user_id. + This field is generally optional, but is required for grading. + """ + return "{context}:{resource_link}:{user_id}".format( + context=urllib.quote(self.context_id), + resource_link=self.resource_link_id, + user_id=self.user_id + ) + + @property + def outcome_service_url(self): + """ + Return URL for storing grades. + + To test LTI on sandbox we must use http scheme. + + While testing locally and on Jenkins, mock_lti_server use http.referer + to obtain scheme, so it is ok to have http(s) anyway. + + The scheme logic is handled in lms/lib/xblock/runtime.py + """ + return self.runtime.handler_url(self, "outcome_service_handler", thirdparty=True).rstrip('/?') + + @property + def prefixed_custom_parameters(self): + """ + Apply prefix to configured custom LTI parameters + + LTI provides a list of default parameters that might be passed as + part of the POST data. These parameters should not be prefixed. + Likewise, The creator of an LTI link can add custom key/value parameters + to a launch which are to be included with the launch of the LTI link. + In this case, we will automatically add `custom_` prefix before this parameters. + See http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html#_Toc316828520 + """ + + # parsing custom parameters to dict + custom_parameters = {} + for custom_parameter in self.custom_parameters: + try: + param_name, param_value = [p.strip() for p in custom_parameter.split('=', 1)] + except ValueError: + _ = self.runtime.service(self, "i18n").ugettext + msg = _('Could not parse custom parameter: {custom_parameter}. Should be "x=y" string.').format( + custom_parameter="{0!r}".format(custom_parameter) + ) + raise LtiError(msg) + + # LTI specs: 'custom_' should be prepended before each custom parameter, as pointed in link above. + if param_name not in LTI_PARAMETERS: + param_name = 'custom_' + param_name + + custom_parameters[unicode(param_name)] = unicode(param_value) + + return custom_parameters + + @property + def is_past_due(self): + """ + Is it now past this problem's due date, including grace period? + """ + due_date = self.due # pylint: disable=no-member + if self.graceperiod is not None and due_date: # pylint: disable=no-member + close_date = due_date + self.graceperiod # pylint: disable=no-member + else: + close_date = due_date + return close_date is not None and datetime.utcnow() > close_date + + def student_view(self, context): + """ + XBlock student view of this component. + + Makes a request to `lti_launch_handler` either + in an iframe or in a new window depending on the + configuration of the instance of this XBlock + + Arguments: + context (dict): XBlock context + + Returns: + xblock.fragment.Fragment: XBlock HTML fragment + """ + fragment = Fragment() + loader = ResourceLoader(__name__) + context.update(self._get_context_for_template()) + fragment.add_content(loader.render_mako_template('/templates/html/student.html', context)) + fragment.add_css(loader.load_unicode('static/css/student.css')) + fragment.add_javascript(loader.load_unicode('static/js/xblock_lti_consumer.js')) + fragment.initialize_js('LtiConsumerXBlock') + return fragment + + @XBlock.handler + def lti_launch_handler(self, request, suffix=''): # pylint: disable=unused-argument + """ + XBlock handler for launching the LTI provider. + + Displays a form which is submitted via Javascript + to send the LTI launch POST request to the LTI + provider. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request + suffix (unicode): Request path after "lti_launch_handler/" + + Returns: + webob.response: HTML LTI launch form + """ + lti_consumer = LtiConsumer(self) + lti_parameters = lti_consumer.get_signed_lti_parameters() + loader = ResourceLoader(__name__) + context = self._get_context_for_template() + context.update({'lti_parameters': lti_parameters}) + template = loader.render_mako_template('/templates/html/lti_launch.html', context) + return Response(template, content_type='text/html') + + @XBlock.handler + def outcome_service_handler(self, request, suffix=''): # pylint: disable=unused-argument + """ + XBlock handler for LTI Outcome Service requests. + + Instantiates an `OutcomeService` instance to handle + requests made by LTI providers to update a user's grade + for this component. + + For details about the LTI Outcome Service see: + https://www.imsglobal.org/specs/ltiomv1p0 + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request + suffix (unicode): Request path after "outcome_service_handler/" + + Returns: + webob.response: XML Outcome Service response + """ + outcome_service = OutcomeService(self) + return Response(outcome_service.handle_request(request), content_type="application/xml") + + @XBlock.handler + def result_service_handler(self, request, suffix=''): + """ + Handler function for LTI 2.0 JSON/REST result service. + + See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html + An example JSON object: + { + "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type" : "Result", + "resultScore" : 0.83, + "comment" : "This is exceptional work." + } + For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json". + We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is + http://localhost:8000/courses/org/num/run/xblock/i4x:;_;_org;_num;_lti;_GUID/handler_noauth/lti_2_0_result_rest_handler/user/<anon_id> + so suffix is of the form "user/<anon_id>" + Failures result in 401, 404, or 500s without any body. Successes result in 200. Again see + http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html + (Note: this prevents good debug messages for the client, so we might want to change this, or the spec) + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request + suffix (unicode): request path after "lti_2_0_result_rest_handler/". expected to be "user/<anon_id>" + + Returns: + webob.response: response to this request. See above for details. + """ + lti_consumer = LtiConsumer(self) + + if self.runtime.debug: + lti_provider_key, lti_provider_secret = self.lti_provider_key_secret + log_authorization_header(request, lti_provider_key, lti_provider_secret) + + if not self.accept_grades_past_due and self.is_past_due: + return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body + + try: + anon_id = parse_handler_suffix(suffix) + except LtiError: + return Response(status=404) # 404 because a part of the URL (denoting the anon user id) is invalid + try: + lti_consumer.verify_result_headers(request, verify_content_type=True) + except LtiError: + return Response(status=401) # Unauthorized in this case. 401 is right + + user = self.runtime.get_real_user(anon_id) + if not user: # that means we can't save to database, as we do not have real user id. + msg = "[LTI]: Real user not found against anon_id: {}".format(anon_id) + log.info(msg) + return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body + + try: + # Call the appropriate LtiConsumer method + args = [] + if request.method == 'PUT': + # Request body should be passed as an argument + # to result handler method on PUT + args.append(request.body) + response_body = getattr(lti_consumer, "{}_result".format(request.method.lower()))(user, *args) + except (AttributeError, LtiError): + return Response(status=404) + + return Response( + json.dumps(response_body), + content_type=LtiConsumer.CONTENT_TYPE_RESULT_JSON, + ) + + def max_score(self): + """ + Returns the configured number of possible points for this component. + + Arguments: + None + + Returns: + float: The number of possible points for this component + """ + return self.weight if self.has_score else None + + def clear_user_module_score(self, user): + """ + Clears the module user state, including grades and comments, and also scoring in db's courseware_studentmodule + + Arguments: + user (django.contrib.auth.models.User): Actual user whose module state is to be cleared + + Returns: + nothing + """ + self.set_user_module_score(user, None, None) + + def set_user_module_score(self, user, score, max_score, comment=u''): + """ + Sets the module user state, including grades and comments, and also scoring in db's courseware_studentmodule + + Arguments: + user (django.contrib.auth.models.User): Actual user whose module state is to be set + score (float): user's numeric score to set. Must be in the range [0.0, 1.0] + max_score (float): max score that could have been achieved on this module + comment (unicode): comments provided by the grader as feedback to the student + + Returns: + nothing + """ + if score is not None and max_score is not None: + scaled_score = score * max_score + else: + scaled_score = None + + self.runtime.rebind_noauth_module_to_user(self, user) + + # have to publish for the progress page... + self.runtime.publish( + self, + 'grade', + { + 'value': scaled_score, + 'max_value': max_score, + 'user_id': user.id, + }, + ) + self.module_score = scaled_score + self.score_comment = comment + + def _get_context_for_template(self): + """ + Returns the context dict for LTI templates. + + Arguments: + None + + Returns: + dict: Context variables for templates + """ + + # use bleach defaults. see https://github.com/jsocol/bleach/blob/master/bleach/__init__.py + # ALLOWED_TAGS are + # ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'strong', 'ul'] + # + # ALLOWED_ATTRIBUTES are + # 'a': ['href', 'title'], + # 'abbr': ['title'], + # 'acronym': ['title'], + # + # This lets all plaintext through. + sanitized_comment = bleach.clean(self.score_comment) + + return { + 'launch_url': self.launch_url.strip(), + 'element_id': self.location.html_id(), # pylint: disable=no-member + 'element_class': self.category, # pylint: disable=no-member + 'launch_target': self.launch_target, + 'display_name': self.display_name, + 'form_url': self.runtime.handler_url(self, 'lti_launch_handler').rstrip('/?'), + 'hide_launch': self.hide_launch, + 'has_score': self.has_score, + 'weight': self.weight, + 'module_score': self.module_score, + 'comment': sanitized_comment, + 'description': self.description, + 'ask_to_send_username': self.ask_to_send_username, + 'ask_to_send_email': self.ask_to_send_email, + 'button_text': self.button_text, + 'inline_height': self.inline_height, + 'modal_height': self.modal_height, + 'modal_width': self.modal_width, + 'accept_grades_past_due': self.accept_grades_past_due, + } diff --git a/lti_consumer/oauth.py b/lti_consumer/oauth.py new file mode 100644 index 0000000000000000000000000000000000000000..c27e54ba930501357740fffe1e4cda966dae7294 --- /dev/null +++ b/lti_consumer/oauth.py @@ -0,0 +1,168 @@ +""" +Utility functions for working with OAuth signatures. +""" + +import logging +import hashlib +import base64 +import urllib + +from oauthlib import oauth1 + +from .exceptions import LtiError + + +log = logging.getLogger(__name__) + + +class SignedRequest(object): + """ + Encapsulates request attributes needed when working + with the `oauthlib.oauth1` API + """ + def __init__(self, **kwargs): + self.uri = kwargs.get('uri') + self.http_method = kwargs.get('http_method') + self.params = kwargs.get('params') + self.oauth_params = kwargs.get('oauth_params') + self.headers = kwargs.get('headers') + self.body = kwargs.get('body') + self.decoded_body = kwargs.get('decoded_body') + self.signature = kwargs.get('signature') + + +def get_oauth_request_signature(key, secret, url, headers, body): + """ + Returns Authorization header for a signed oauth request. + + Arguments: + key (str): LTI provider key + secret (str): LTI provider secret + url (str): URL for the signed request + header (str): HTTP headers for the signed request + body (str): Body of the signed request + + Returns: + str: Authorization header for the OAuth signed request + """ + client = oauth1.Client(client_key=unicode(key), client_secret=unicode(secret)) + try: + # Add Authorization header which looks like: + # Authorization: OAuth oauth_nonce="80966668944732164491378916897", + # oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", + # oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D" + __, headers, __ = client.sign( + unicode(url.strip()), + http_method=u'POST', + body=body, + headers=headers + ) + except ValueError: # Scheme not in url. + raise LtiError("Failed to sign oauth request") + + return headers['Authorization'] + + +def verify_oauth_body_signature(request, lti_provider_secret, service_url): + """ + Verify grade request from LTI provider using OAuth body signing. + + Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html:: + + This specification extends the OAuth signature to include integrity checks on HTTP request bodies + with content types other than application/x-www-form-urlencoded. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request + lti_provider_secret (str): Secret key for the LTI provider + service_url (str): URL that the request was made to + content_type (str): HTTP content type of the request + + Raises: + LtiError if request is incorrect. + """ + + headers = { + 'Authorization': unicode(request.headers.get('Authorization')), + 'Content-Type': request.content_type, + } + + sha1 = hashlib.sha1() + sha1.update(request.body) + oauth_body_hash = base64.b64encode(sha1.digest()) # pylint: disable=E1121 + oauth_params = oauth1.rfc5849.signature.collect_parameters(headers=headers, exclude_oauth_signature=False) + oauth_headers = dict(oauth_params) + oauth_signature = oauth_headers.pop('oauth_signature') + mock_request_lti_1 = SignedRequest( + uri=unicode(urllib.unquote(service_url)), + http_method=unicode(request.method), + params=oauth_headers.items(), + signature=oauth_signature + ) + mock_request_lti_2 = SignedRequest( + uri=unicode(urllib.unquote(request.url)), + http_method=unicode(request.method), + params=oauth_headers.items(), + signature=oauth_signature + ) + if oauth_body_hash != oauth_headers.get('oauth_body_hash'): + log.error( + "OAuth body hash verification failed, provided: %s, " + "calculated: %s, for url: %s, body is: %s", + oauth_headers.get('oauth_body_hash'), + oauth_body_hash, + service_url, + request.body + ) + raise LtiError("OAuth body hash verification is failed.") + + if (not oauth1.rfc5849.signature.verify_hmac_sha1(mock_request_lti_1, lti_provider_secret) and not + oauth1.rfc5849.signature.verify_hmac_sha1(mock_request_lti_2, lti_provider_secret)): + log.error( + "OAuth signature verification failed, for " + "headers:%s url:%s method:%s", + oauth_headers, + service_url, + unicode(request.method) + ) + raise LtiError("OAuth signature verification has failed.") + + return True + + +def log_authorization_header(request, client_key, client_secret): + """ + Helper function that logs proper HTTP Authorization header for a given request + + Used only in debug situations, this logs the correct Authorization header based on + the request header and body according to OAuth 1 Body signing + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object to log Authorization header for + + Returns: + nothing + """ + sha1 = hashlib.sha1() + sha1.update(request.body) + oauth_body_hash = unicode(base64.b64encode(sha1.digest())) # pylint: disable=too-many-function-args + log.debug("[LTI] oauth_body_hash = %s", oauth_body_hash) + client = oauth1.Client(client_key, client_secret) + params = client.get_oauth_params(request) + params.append((u'oauth_body_hash', oauth_body_hash)) + mock_request = SignedRequest( + uri=unicode(urllib.unquote(request.url)), + headers=request.headers, + body=u"", + decoded_body=u"", + oauth_params=params, + http_method=unicode(request.method), + ) + sig = client.get_oauth_signature(mock_request) + mock_request.oauth_params.append((u'oauth_signature', sig)) + + __, headers, _ = client._render(mock_request) # pylint: disable=protected-access + log.debug( + "\n\n#### COPY AND PASTE AUTHORIZATION HEADER ####\n%s\n####################################\n\n", + headers['Authorization'] + ) diff --git a/lti_consumer/outcomes.py b/lti_consumer/outcomes.py new file mode 100644 index 0000000000000000000000000000000000000000..f1ca15056d1e6cc556c41dad5b19c29ef9c10fdc --- /dev/null +++ b/lti_consumer/outcomes.py @@ -0,0 +1,205 @@ +""" +This module adds support for the LTI Outcomes Management Service. + +For more details see: +https://www.imsglobal.org/specs/ltiomv1p0 +""" + +import logging +import urllib + +from lxml import etree +from xml.sax.saxutils import escape + +from xblockutils.resources import ResourceLoader + +from .exceptions import LtiError +from .oauth import verify_oauth_body_signature + + +log = logging.getLogger(__name__) + + +def parse_grade_xml_body(body): + """ + Parses values from the Outcome Service XML. + + XML body should contain nsmap with namespace, that is specified in LTI specs. + + Arguments: + body (str): XML Outcome Service request body + + Returns: + tuple: imsx_messageIdentifier, sourcedId, score, action + + Raises: + LtiError + if submitted score is outside the permitted range + if the XML is missing required entities + if there was a problem parsing the XML body + """ + lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" + namespaces = {'def': lti_spec_namespace} + data = body.strip().encode('utf-8') + + try: + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') # pylint: disable=no-member + root = etree.fromstring(data, parser=parser) # pylint: disable=no-member + except etree.XMLSyntaxError as ex: + raise LtiError(ex.message or 'Body is not valid XML') + + try: + imsx_message_identifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text or '' + except IndexError: + raise LtiError('Failed to parse imsx_messageIdentifier from XML request body') + + try: + body = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] + except IndexError: + raise LtiError('Failed to parse imsx_POXBody from XML request body') + + try: + action = body.getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') + except IndexError: + raise LtiError('Failed to parse action from XML request body') + + try: + sourced_id = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + except IndexError: + raise LtiError('Failed to parse sourcedId from XML request body') + + try: + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + except IndexError: + raise LtiError('Failed to parse score textString from XML request body') + + # Raise exception if score is not float or not in range 0.0-1.0 regarding spec. + score = float(score) + if not 0.0 <= score <= 1.0: + raise LtiError('score value outside the permitted range of 0.0-1.0') + + return imsx_message_identifier, sourced_id, score, action + + +class OutcomeService(object): + """ + Service for handling LTI Outcome Management Service requests. + + For more details see: + https://www.imsglobal.org/specs/ltiomv1p0 + """ + + def __init__(self, xblock): + self.xblock = xblock + + def handle_request(self, request): + """ + Handler for Outcome Service requests. + + Parses and validates XML request body. Currently, only the + replaceResultRequest action is supported. + + Example of request body from LTI provider:: + + <?xml version = "1.0" encoding = "UTF-8"?> + <imsx_POXEnvelopeRequest xmlns = "some_link (may be not required)"> + <imsx_POXHeader> + <imsx_POXRequestHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>528243ba5241b</imsx_messageIdentifier> + </imsx_POXRequestHeaderInfo> + </imsx_POXHeader> + <imsx_POXBody> + <replaceResultRequest> + <resultRecord> + <sourcedGUID> + <sourcedId>feb-123-456-2929::28883</sourcedId> + </sourcedGUID> + <result> + <resultScore> + <language>en-us</language> + <textString>0.4</textString> + </resultScore> + </result> + </resultRecord> + </replaceResultRequest> + </imsx_POXBody> + </imsx_POXEnvelopeRequest> + + See /templates/xml/outcome_service_response.xml for the response body format. + + Arguments: + request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request + + Returns: + str: Outcome Service XML response + """ + resource_loader = ResourceLoader(__name__) + response_xml_template = resource_loader.load_unicode('/templates/xml/outcome_service_response.xml') + + # Returns when `action` is unsupported. + # Supported actions: + # - replaceResultRequest. + unsupported_values = { + 'imsx_codeMajor': 'unsupported', + 'imsx_description': 'Target does not support the requested operation.', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } + # Returns if: + # - past due grades are not accepted and grade is past due + # - score is out of range + # - can't parse response from TP; + # - can't verify OAuth signing or OAuth signing is incorrect. + failure_values = { + 'imsx_codeMajor': 'failure', + 'imsx_description': 'The request has failed.', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } + + if not self.xblock.accept_grades_past_due and self.xblock.is_past_due: + failure_values['imsx_description'] = "Grade is past due" + return response_xml_template.format(**failure_values) + + try: + imsx_message_identifier, sourced_id, score, action = parse_grade_xml_body(request.body) + except LtiError as ex: # pylint: disable=no-member + body = escape(request.body) if request.body else '' + error_message = "Request body XML parsing error: {} {}".format(ex.message, body) + log.debug("[LTI]: %s" + error_message) + failure_values['imsx_description'] = error_message + return response_xml_template.format(**failure_values) + + # Verify OAuth signing. + __, secret = self.xblock.lti_provider_key_secret + try: + verify_oauth_body_signature(request, secret, self.xblock.outcome_service_url) + except (ValueError, LtiError) as ex: + failure_values['imsx_messageIdentifier'] = escape(imsx_message_identifier) + error_message = "OAuth verification error: " + escape(ex.message) + failure_values['imsx_description'] = error_message + log.debug("[LTI]: " + error_message) + return response_xml_template.format(**failure_values) + + real_user = self.xblock.runtime.get_real_user(urllib.unquote(sourced_id.split(':')[-1])) + if not real_user: # that means we can't save to database, as we do not have real user id. + failure_values['imsx_messageIdentifier'] = escape(imsx_message_identifier) + failure_values['imsx_description'] = "User not found." + return response_xml_template.format(**failure_values) + + if action == 'replaceResultRequest': + self.xblock.set_user_module_score(real_user, score, self.xblock.max_score()) + + values = { + 'imsx_codeMajor': 'success', + 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourced_id, score=score), + 'imsx_messageIdentifier': escape(imsx_message_identifier), + 'response': '<replaceResultResponse/>' + } + log.debug("[LTI]: Grade is saved.") + return response_xml_template.format(**values) + + unsupported_values['imsx_messageIdentifier'] = escape(imsx_message_identifier) + log.debug("[LTI]: Incorrect action.") + return response_xml_template.format(**unsupported_values) diff --git a/lti_consumer/static/css/student.css b/lti_consumer/static/css/student.css new file mode 100644 index 0000000000000000000000000000000000000000..474434ad15be6a56edf684be61f6d704b94866a4 --- /dev/null +++ b/lti_consumer/static/css/student.css @@ -0,0 +1 @@ +.xblock-student_view.xblock-student_view-lti_consumer h2.problem-header{display:inline-block}.xblock-student_view.xblock-student_view-lti_consumer div.problem-progress{display:inline-block;padding-left:5px;color:#666;font-weight:100;font-size:1em}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer{margin:0 auto}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .wrapper-lti-link{font-size:14px;background-color:#f6f6f6;padding:20px}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .wrapper-lti-link .lti-link{margin-bottom:0;text-align:right}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .wrapper-lti-link .lti-link button{font-size:13px;line-height:20.72px}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .lti-modal{top:80px !important}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .lti-modal .inner-wrapper{height:100%;padding:0 0 0 0}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer form.ltiLaunchForm{display:none}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer iframe.ltiLaunchFrame{width:100%;height:100%;display:block;border:0px}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer h4.problem-feedback-label{font-weight:100;font-size:1em;font-family:"Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer div.problem-feedback{margin-top:5px;margin-bottom:5px} diff --git a/lti_consumer/static/js/xblock_lti_consumer.js b/lti_consumer/static/js/xblock_lti_consumer.js new file mode 100644 index 0000000000000000000000000000000000000000..e31446c8c07f4362fa3a013914068d107af98d05 --- /dev/null +++ b/lti_consumer/static/js/xblock_lti_consumer.js @@ -0,0 +1,98 @@ +function LtiConsumerXBlock(runtime, element) { + $(function ($) { + // Adapted from leanModal v1.1 by Ray Stone - http://finelysliced.com.au + // Dual licensed under the MIT and GPL + // Renamed leanModal to iframeModal to avoid clash with platform-provided leanModal + // which removes the href attribute from iframe elements upon modal closing + $.fn.extend({ + iframeModal: function (options) { + var $trigger = $(this); + var defaults = {top: 100, overlay: 0.5, closeButton: null}; + var overlay = $("<div id='lean_overlay'></div>"); + $("body").append(overlay); + options = $.extend(defaults, options); + return this.each(function () { + var o = options; + $(this).click(function (e) { + var modal_id = $(this).data("target"); + $("#lean_overlay").click(function () { + close_modal(modal_id) + }); + $(o.closeButton).click(function () { + close_modal(modal_id) + }); + var modal_height = $(modal_id).outerHeight(); + var modal_width = $(modal_id).outerWidth(); + $("#lean_overlay").css({"display": "block", opacity: 0}); + $("#lean_overlay").fadeTo(200, o.overlay); + $(modal_id).css({ + "display": "block", + "position": "fixed", + "opacity": 0, + "z-index": 11000, + "left": 50 + "%", + "margin-left": -(modal_width / 2) + "px", + "top": o.top + "px" + }); + $(modal_id).fadeTo(200, 1); + e.preventDefault(); + + /* Manage focus for modal dialog */ + var iframe = $(modal_id).find('iframe')[0].contentWindow; + + /* Set focus on close button */ + $(o.closeButton).focus(); + + /* Redirect close button to iframe */ + $(o.closeButton).on('keydown', function (e) { + if (e.which === 9) { + e.preventDefault(); + $(modal_id).find('iframe')[0].contentWindow.focus(); + } + }); + + /* Redirect non-iframe tab to close button */ + var $inputs = $('select, input, textarea, button, a').filter(':visible').not(o.closeButton); + $inputs.on('focus', function(e) { + e.preventDefault(); + $(options.closeButton).focus(); + }); + }); + }); + function close_modal(modal_id) { + $('select, input, textarea, button, a').off('focus'); + $("#lean_overlay").fadeOut(200); + $(modal_id).css({"display": "none"}) + $trigger.focus(); + } + } + }); + + var $element = $(element); + var $ltiContainer = $element.find('.lti-consumer-container'); + var askToSendUsername = $ltiContainer.data('ask-to-send-username') == 'True'; + var askToSendEmail = $ltiContainer.data('ask-to-send-email') == 'True'; + + // Apply click handler to modal launch button + $element.find('.btn-lti-modal').iframeModal({top: 200, closeButton: '.close-modal'}); + + // Apply click handler to new window launch button + $element.find('.btn-lti-new-window').click(function(){ + var launch = true; + + // If this instance is configured to require username and/or email, ask user if it is okay to send them + // Do not launch if it is not okay + if(askToSendUsername && askToSendEmail) { + launch = confirm(gettext("Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.")); + } else if (askToSendUsername) { + launch = confirm(gettext("Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.")); + } else if (askToSendEmail) { + launch = confirm(gettext("Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.")); + } + + if (launch) { + window.open($(this).data('target')); + } + }); + }); +} diff --git a/lti_consumer/static/sass/student.scss b/lti_consumer/static/sass/student.scss new file mode 100644 index 0000000000000000000000000000000000000000..a56877ecb495657a66017ce429fbed10ee352830 --- /dev/null +++ b/lti_consumer/static/sass/student.scss @@ -0,0 +1,64 @@ +.xblock-student_view.xblock-student_view-lti_consumer { + h2.problem-header { + display: inline-block; + } + + div.problem-progress { + display: inline-block; + padding-left: 5px; + color: #666; + font-weight: 100; + font-size: 1em; + } + + div.lti_consumer { + margin: 0 auto; + + .wrapper-lti-link { + font-size: 14px; + background-color: #f6f6f6; + padding: 20px; + + .lti-link { + margin-bottom: 0; + text-align: right; + + button { + font-size: 13px; + line-height: 20.72px; + } + } + } + + .lti-modal { + top: 80px !important; + + .inner-wrapper { + height: 100%; + padding: 0 0 0 0; + } + } + + form.ltiLaunchForm { + display: none; + } + + iframe.ltiLaunchFrame { + width: 100%; + height: 100%; + display: block; + border: 0px; + } + + h4.problem-feedback-label { + font-weight: 100; + font-size: 1em; + font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif; + } + + div.problem-feedback { + margin-top: 5px; + margin-bottom: 5px; + } + } +} diff --git a/lti_consumer/templates/html/lti_iframe.html b/lti_consumer/templates/html/lti_iframe.html new file mode 100644 index 0000000000000000000000000000000000000000..865476cc681ead58063ff7a1392fa2ccc4f7b5d6 --- /dev/null +++ b/lti_consumer/templates/html/lti_iframe.html @@ -0,0 +1,9 @@ +<iframe + title="External Tool Content" + class="ltiLaunchFrame" + name="ltiFrame-${element_id}" + src="${form_url}" + allowfullscreen="true" + webkitallowfullscreen="true" + mozallowfullscreen="true" +></iframe> diff --git a/lti_consumer/templates/html/lti_launch.html b/lti_consumer/templates/html/lti_launch.html new file mode 100644 index 0000000000000000000000000000000000000000..5440c61ba3f9b925802e9fc64bac980402b23bc4 --- /dev/null +++ b/lti_consumer/templates/html/lti_launch.html @@ -0,0 +1,34 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>LTI</title> + </head> + <body> + ## This form will be hidden. + ## LTI module JavaScript will trigger a "submit" on the form, and the + ## result will be rendered instead. + <form + id="lti-${element_id}" + action="${launch_url}" + method="post" + encType="application/x-www-form-urlencoded" + style="display:none;" + > + + % for param_name, param_value in lti_parameters.items(): + <input name="${param_name}" value="${param_value}" /> + % endfor + + <input type="submit" value="Press to Launch" /> + </form> + <script type="text/javascript"> + (function (d) { + var element = d.getElementById("lti-${element_id}"); + if (element) { + element.submit(); + } + }(document)); + </script> + </body> +</html> diff --git a/lti_consumer/templates/html/student.html b/lti_consumer/templates/html/student.html new file mode 100644 index 0000000000000000000000000000000000000000..6c5070be1d272e923df33f35f588d8e7cb29d3f1 --- /dev/null +++ b/lti_consumer/templates/html/student.html @@ -0,0 +1,85 @@ +<h2 class="problem-header"> + ## Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS + ${display_name} (External resource) +</h2> + +% if has_score and weight: + <div class="problem-progress"> + % if module_score is not None: + (${"{points} / {total_points} points".format(points=module_score, total_points=weight)}) + % else: + (${"{total_points} points possible".format(total_points=weight)}) + % endif + </div> +% endif + +<div + id="${element_id}" + class="${element_class} lti-consumer-container" + data-ask-to-send-username="${ask_to_send_username}" + data-ask-to-send-email="${ask_to_send_email}" +> + +% if launch_url and not hide_launch: + % if launch_target in ['modal', 'new_window']: + <section class="wrapper-lti-link"> + % if description: + <div class="lti-description">${description}</div> + % endif + <p class="lti-link external"> + % if launch_target == 'modal': + <button + class="btn btn-pl-primary btn-base btn-lti-modal" + data-target="#${element_id + '-lti-modal'}" + > + ${button_text or 'View resource in a modal window'} <i class="icon fa fa-external-link"></i> + </button> + % else: + <button + class="btn btn-pl-primary btn-base btn-lti-new-window" + data-target="${form_url}" + > + ${button_text or 'View resource in a new window'} <i class="icon fa fa-external-link"></i> + </button> + % endif + </p> + </section> + % endif + % if launch_target == 'modal': + <section + id="${element_id}-lti-modal" + class="modal lti-modal" + aria-hidden="true" + style="width:${modal_width}px; height:${modal_height}px;" + > + <div class="inner-wrapper" role="dialog" aria-labelledby="lti-modal-title"> + <button class="close-modal" tabindex="1"> + <i class="icon fa fa-remove"></i> + <span class="sr">Close</span> + </button> + ## The result of the LTI launch form submit will be rendered here. + <%include file="templates/html/lti_iframe.html"/> + </div> + </section> + % endif + % if launch_target == 'iframe': + <div style="height:${inline_height}px;"> + ## The result of the LTI launch form submit will be rendered here. + <%include file="templates/html/lti_iframe.html"/> + </div> + % endif +% elif not hide_launch: + <h3 class="error_message"> + Please provide launch_url. Click "Edit", and fill in the required fields. + </h3> +% endif + +% if has_score and comment: + <h4 class="problem-feedback-label">Feedback on your work from the grader:</h4> + <div class="problem-feedback"> + ## sanitized with bleach in view + ${comment} + </div> +% endif + +</div> diff --git a/lti_consumer/templates/xml/outcome_service_response.xml b/lti_consumer/templates/xml/outcome_service_response.xml new file mode 100644 index 0000000000000000000000000000000000000000..2fd7b0f0661c84e4adc32406fd5e6b182551fc77 --- /dev/null +++ b/lti_consumer/templates/xml/outcome_service_response.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8"?> +<imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXResponseHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>{imsx_messageIdentifier}</imsx_messageIdentifier> + <imsx_statusInfo> + <imsx_codeMajor>{imsx_codeMajor}</imsx_codeMajor> + <imsx_severity>status</imsx_severity> + <imsx_description>{imsx_description}</imsx_description> + <imsx_messageRefIdentifier> + </imsx_messageRefIdentifier> + </imsx_statusInfo> + </imsx_POXResponseHeaderInfo> + </imsx_POXHeader> + <imsx_POXBody>{response}</imsx_POXBody> +</imsx_POXEnvelopeResponse> diff --git a/lti_consumer/tests/__init__.py b/lti_consumer/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cd459c4aca8d01b900f79d76659e12a57ee9254a --- /dev/null +++ b/lti_consumer/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Module containing tests for xblock-lti-consumer +""" diff --git a/lti_consumer/tests/unit/__init__.py b/lti_consumer/tests/unit/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..11bc4214f07cc769c01612fa3b70ec960b6d41a0 --- /dev/null +++ b/lti_consumer/tests/unit/__init__.py @@ -0,0 +1,3 @@ +""" +Module containing unit tests for xblock-lti-consumer +""" diff --git a/lti_consumer/tests/unit/test_lti.py b/lti_consumer/tests/unit/test_lti.py new file mode 100644 index 0000000000000000000000000000000000000000..c6452ebba02189032cc4ca2aaa1e05df45db22f1 --- /dev/null +++ b/lti_consumer/tests/unit/test_lti.py @@ -0,0 +1,297 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for lti_consumer.lti module +""" + +import unittest + +from mock import Mock, PropertyMock, patch + +from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_request +from lti_consumer.tests.unit.test_lti_consumer import TestLtiConsumerXBlock + +from lti_consumer.lti import parse_result_json, LtiConsumer +from lti_consumer.exceptions import LtiError + + +INVALID_JSON_INPUTS = [ + ([ + u"kk", # ValueError + u"{{}", # ValueError + u"{}}", # ValueError + 3, # TypeError + {}, # TypeError + ], u"Supplied JSON string in request body could not be decoded"), + ([ + u"3", # valid json, not array or object + u"[]", # valid json, array too small + u"[3, {}]", # valid json, 1st element not an object + ], u"Supplied JSON string is a list that does not contain an object as the first element"), + ([ + u'{"@type": "NOTResult"}', # @type key must have value 'Result' + ], u"JSON object does not contain correct @type attribute"), + ([ + # @context missing + u'{"@type": "Result", "resultScore": 0.1}', + ], u"JSON object does not contain required key"), + ([ + u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 100}''' # score out of range + ], u"score value outside the permitted range of 0.0-1.0."), + ([ + u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": -2}''' # score out of range + ], u"score value outside the permitted range of 0.0-1.0."), + ([ + u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": "1b"}''', # score ValueError + u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": {}}''', # score TypeError + ], u"Could not convert resultScore to float"), +] + +VALID_JSON_INPUTS = [ + (u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 0.1}''', 0.1, u""), # no comment means we expect "" + (u''' + [{"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@id": "anon_id:abcdef0123456789", + "resultScore": 0.1}]''', 0.1, u""), # OK to have array of objects -- just take the first. @id is okay too + (u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 0.1, + "comment": "ಠ益ಠ"}''', 0.1, u"ಠ益ಠ"), # unicode comment + (u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result"}''', None, u""), # no score means we expect None + (u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 0.0}''', 0.0, u""), # test lower score boundary + (u''' + {"@type": "Result", + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "resultScore": 1.0}''', 1.0, u""), # test upper score boundary +] + +GET_RESULT_RESPONSE = { + "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", + "@type": "Result", +} + + +class TestParseResultJson(unittest.TestCase): + """ + Unit tests for `lti_consumer.lti.parse_result_json` + """ + + def test_invalid_json(self): + """ + Test invalid json raises exception + """ + for error_inputs, error_message in INVALID_JSON_INPUTS: + for error_input in error_inputs: + with self.assertRaisesRegexp(LtiError, error_message): + parse_result_json(error_input) + + def test_valid_json(self): + """ + Test valid json returns expected values + """ + for json_str, expected_score, expected_comment in VALID_JSON_INPUTS: + score, comment = parse_result_json(json_str) + self.assertEqual(score, expected_score) + self.assertEqual(comment, expected_comment) + + +class TestLtiConsumer(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumer + """ + + def setUp(self): + super(TestLtiConsumer, self).setUp() + self.lti_consumer = LtiConsumer(self.xblock) + + @patch( + 'lti_consumer.lti.get_oauth_request_signature', + Mock(return_value=( + 'OAuth oauth_nonce="fake_nonce", ' + 'oauth_timestamp="fake_timestamp", oauth_version="fake_version", oauth_signature_method="fake_method", ' + 'oauth_consumer_key="fake_consumer_key", oauth_signature="fake_signature"' + )) + ) + @patch( + 'lti_consumer.lti_consumer.LtiConsumerXBlock.prefixed_custom_parameters', + PropertyMock(return_value={u'custom_param_1': 'custom1', u'custom_param_2': 'custom2'}) + ) + @patch( + 'lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', + PropertyMock(return_value=('t', 's')) + ) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.user_id', PropertyMock(return_value=FAKE_USER_ID)) + def test_get_signed_lti_parameters(self): + """ + Test `get_signed_lti_parameters` returns the correct dict + """ + expected_lti_parameters = { + u'user_id': self.lti_consumer.xblock.user_id, + u'oauth_callback': u'about:blank', + u'launch_presentation_return_url': '', + u'lti_message_type': u'basic-lti-launch-request', + u'lti_version': 'LTI-1p0', + u'roles': self.lti_consumer.xblock.role, + u'resource_link_id': self.lti_consumer.xblock.resource_link_id, + u'lis_result_sourcedid': self.lti_consumer.xblock.lis_result_sourcedid, + u'context_id': self.lti_consumer.xblock.context_id, + u'lis_outcome_service_url': self.lti_consumer.xblock.outcome_service_url, + 'lis_person_sourcedid': 'edx', + 'lis_person_contact_email_primary': 'edx@example.com', + u'custom_param_1': 'custom1', + u'custom_param_2': 'custom2', + u'oauth_nonce': 'fake_nonce', + 'oauth_timestamp': 'fake_timestamp', + 'oauth_version': 'fake_version', + 'oauth_signature_method': 'fake_method', + 'oauth_consumer_key': 'fake_consumer_key', + 'oauth_signature': u'fake_signature' + } + self.lti_consumer.xblock.has_score = True + self.lti_consumer.xblock.ask_to_send_username = True + self.lti_consumer.xblock.ask_to_send_email = True + + self.lti_consumer.xblock.runtime.get_real_user.return_value = Mock(email='edx@example.com', username='edx') + self.assertEqual(self.lti_consumer.get_signed_lti_parameters(), expected_lti_parameters) + + # Test that `lis_person_sourcedid` and `lis_person_contact_email_primary` are not included + # in the returned LTI parameters when a user cannot be found + self.lti_consumer.xblock.runtime.get_real_user.return_value = {} + del expected_lti_parameters['lis_person_sourcedid'] + del expected_lti_parameters['lis_person_contact_email_primary'] + self.assertEqual(self.lti_consumer.get_signed_lti_parameters(), expected_lti_parameters) + + def test_get_result(self): + """ + Test `get_result` returns valid json response + """ + self.xblock.module_score = 0.9 + self.xblock.score_comment = 'Great Job!' + response = dict(GET_RESULT_RESPONSE) + response.update({ + "resultScore": self.xblock.module_score, + "comment": self.xblock.score_comment + }) + self.assertEqual(self.lti_consumer.get_result(Mock()), response) + + self.xblock.module_score = None + self.xblock.score_comment = '' + self.assertEqual(self.lti_consumer.get_result(Mock()), GET_RESULT_RESPONSE) + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.clear_user_module_score') + def test_delete_result(self, mock_clear): + """ + Test `delete_result` calls `LtiConsumerXBlock.clear_user_module_score` + """ + user = Mock() + response = self.lti_consumer.delete_result(user) + + mock_clear.assert_called_with(user) + self.assertEqual(response, {}) + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.max_score', Mock(return_value=1.0)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.set_user_module_score') + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.clear_user_module_score') + @patch('lti_consumer.lti.parse_result_json') + def test_put_result(self, mock_parse, mock_clear, mock_set): + """ + Test `put_result` calls `LtiConsumerXBlock.set_user_module_score` + or `LtiConsumerXblock.clear_user_module_score` if resultScore not included in request + """ + user = Mock() + score = 0.9 + comment = 'Great Job!' + + mock_parse.return_value = (score, comment) + response = self.lti_consumer.put_result(user, '') + mock_set.assert_called_with(user, score, 1.0, comment) + self.assertEqual(response, {}) + + mock_parse.return_value = (None, '') + response = self.lti_consumer.put_result(user, '') + mock_clear.assert_called_with(user) + self.assertEqual(response, {}) + + @patch('lti_consumer.lti.log') + def test_verify_result_headers_verify_content_type_true(self, mock_log): + """ + Test wrong content type raises exception if `verify_content_type` is True + """ + request = make_request('') + + with self.assertRaises(LtiError): + self.lti_consumer.verify_result_headers(request) + self.assertTrue(mock_log.called) + + @patch('lti_consumer.lti.verify_oauth_body_signature', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + def test_verify_result_headers_verify_content_type_false(self): + """ + Test content type check skipped if `verify_content_type` is False + """ + request = make_request('') + request.environ['CONTENT_TYPE'] = LtiConsumer.CONTENT_TYPE_RESULT_JSON + response = self.lti_consumer.verify_result_headers(request, False) + + self.assertTrue(response) + + @patch('lti_consumer.lti.verify_oauth_body_signature', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + def test_verify_result_headers_valid(self): + """ + Test True is returned if request is valid + """ + request = make_request('') + request.environ['CONTENT_TYPE'] = LtiConsumer.CONTENT_TYPE_RESULT_JSON + response = self.lti_consumer.verify_result_headers(request) + + self.assertTrue(response) + + @patch('lti_consumer.lti.verify_oauth_body_signature', Mock(side_effect=LtiError)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + @patch('lti_consumer.lti.log') + def test_verify_result_headers_lti_error(self, mock_log): + """ + Test exception raised if request header verification raises error + """ + request = make_request('') + request.environ['CONTENT_TYPE'] = LtiConsumer.CONTENT_TYPE_RESULT_JSON + + with self.assertRaises(LtiError): + self.lti_consumer.verify_result_headers(request) + self.assertTrue(mock_log.called) + + @patch('lti_consumer.lti.verify_oauth_body_signature', Mock(side_effect=ValueError)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + @patch('lti_consumer.lti.log') + def test_verify_result_headers_value_error(self, mock_log): + """ + Test exception raised if request header verification raises error + """ + request = make_request('') + request.environ['CONTENT_TYPE'] = LtiConsumer.CONTENT_TYPE_RESULT_JSON + + with self.assertRaises(LtiError): + self.lti_consumer.verify_result_headers(request) + self.assertTrue(mock_log.called) diff --git a/lti_consumer/tests/unit/test_lti_consumer.py b/lti_consumer/tests/unit/test_lti_consumer.py new file mode 100644 index 0000000000000000000000000000000000000000..8d8d4adff1a84fef0134f312782d0abbf4e277b5 --- /dev/null +++ b/lti_consumer/tests/unit/test_lti_consumer.py @@ -0,0 +1,664 @@ +""" +Unit tests for LtiConsumerXBlock +""" + +import unittest + +from datetime import datetime, timedelta +from mock import Mock, PropertyMock, patch + +from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_xblock, make_request + +from lti_consumer.lti_consumer import LtiConsumerXBlock, parse_handler_suffix +from lti_consumer.exceptions import LtiError + + +HTML_PROBLEM_PROGRESS = '<div class="problem-progress">' +HTML_ERROR_MESSAGE = '<h3 class="error_message">' +HTML_LAUNCH_MODAL_BUTTON = 'btn-lti-modal' +HTML_LAUNCH_NEW_WINDOW_BUTTON = 'btn-lti-new-window' +HTML_IFRAME = '<iframe' + + +class TestLtiConsumerXBlock(unittest.TestCase): + """ + Unit tests for LtiConsumerXBlock.max_score() + """ + + def setUp(self): + super(TestLtiConsumerXBlock, self).setUp() + self.xblock_attributes = { + 'launch_url': 'http://www.example.com', + } + self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes) + + +class TestProperties(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock properties + """ + + def test_descriptor(self): + """ + Test `descriptor` returns the XBLock object + """ + self.assertEqual(self.xblock.descriptor, self.xblock) + + def test_context_id(self): + """ + Test `context_id` returns unicode course id + """ + self.assertEqual(self.xblock.context_id, unicode(self.xblock.course_id)) # pylint: disable=no-member + + def test_role(self): + """ + Test `role` returns the correct LTI role string + """ + self.xblock.runtime.get_user_role.return_value = 'student' + self.assertEqual(self.xblock.role, 'Student') + + self.xblock.runtime.get_user_role.return_value = 'guest' + self.assertEqual(self.xblock.role, 'Student') + + self.xblock.runtime.get_user_role.return_value = 'staff' + self.assertEqual(self.xblock.role, 'Administrator') + + self.xblock.runtime.get_user_role.return_value = 'instructor' + self.assertEqual(self.xblock.role, 'Instructor') + + def test_course(self): + """ + Test `course` calls modulestore.get_course + """ + mock_get_course = self.xblock.runtime.descriptor_runtime.modulestore.get_course + mock_get_course.return_value = None + course = self.xblock.course + + self.assertTrue(mock_get_course.called) + self.assertIsNone(course) + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.course') + def test_lti_provider_key_secret(self, mock_course): + """ + Test `lti_provider_key_secret` returns correct key and secret + """ + provider = 'lti_provider' + key = 'test' + secret = 'secret' + self.xblock.lti_id = provider + type(mock_course).lti_passports = PropertyMock(return_value=["{}:{}:{}".format(provider, key, secret)]) + lti_provider_key, lti_provider_secret = self.xblock.lti_provider_key_secret + + self.assertEqual(lti_provider_key, key) + self.assertEqual(lti_provider_secret, secret) + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.course') + def test_lti_provider_key_secret_not_found(self, mock_course): + """ + Test `lti_provider_key_secret` returns correct key and secret + """ + provider = 'lti_provider' + key = 'test' + secret = 'secret' + self.xblock.lti_id = 'wrong_provider' + type(mock_course).lti_passports = PropertyMock(return_value=["{}:{}:{}".format(provider, key, secret)]) + lti_provider_key, lti_provider_secret = self.xblock.lti_provider_key_secret + + self.assertEqual(lti_provider_key, '') + self.assertEqual(lti_provider_secret, '') + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.course') + def test_lti_provider_key_secret_corrupt_lti_passport(self, mock_course): + """ + Test `lti_provider_key_secret` when a corrupt lti_passport is encountered + """ + provider = 'lti_provider' + key = 'test' + secret = 'secret' + self.xblock.lti_id = provider + type(mock_course).lti_passports = PropertyMock(return_value=["{}{}{}".format(provider, key, secret)]) + + with self.assertRaises(LtiError): + __, __ = self.xblock.lti_provider_key_secret + + def test_user_id(self): + """ + Test `user_id` returns the user_id string + """ + self.xblock.runtime.anonymous_student_id = FAKE_USER_ID + self.assertEqual(self.xblock.user_id, FAKE_USER_ID) + + def test_user_id_url_encoded(self): + """ + Test `user_id` url encodes the user id + """ + self.xblock.runtime.anonymous_student_id = 'user_id?&. ' + self.assertEqual(self.xblock.user_id, 'user_id%3F%26.%20') + + def test_user_id_none(self): + """ + Test `user_id` raises LtiError when the user id cannot be returned + """ + self.xblock.runtime.anonymous_student_id = None + with self.assertRaises(LtiError): + __ = self.xblock.user_id + + def test_resource_link_id(self): + """ + Test `resource_link_id` returns appropriate string + """ + self.assertEqual( + self.xblock.resource_link_id, + "{}-{}".format(self.xblock.runtime.hostname, self.xblock.location.html_id()) # pylint: disable=no-member + ) + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.context_id') + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.resource_link_id') + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.user_id', PropertyMock(return_value=FAKE_USER_ID)) + def test_lis_result_sourcedid(self, mock_resource_link_id, mock_context_id): + """ + Test `lis_result_sourcedid` returns appropriate string + """ + mock_resource_link_id.__get__ = Mock(return_value='resource_link_id') + mock_context_id.__get__ = Mock(return_value='context_id') + + self.assertEqual(self.xblock.lis_result_sourcedid, "context_id:resource_link_id:{}".format(FAKE_USER_ID)) + + def test_outcome_service_url(self): + """ + Test `outcome_service_url` calls `runtime.handler_url` with thirdparty kwarg + """ + handler_url = 'http://localhost:8005/outcome_service_handler' + self.xblock.runtime.handler_url = Mock(return_value="{}/?".format(handler_url)) + url = self.xblock.outcome_service_url + + self.xblock.runtime.handler_url.assert_called_with(self.xblock, 'outcome_service_handler', thirdparty=True) + self.assertEqual(url, handler_url) + + def test_prefixed_custom_parameters(self): + """ + Test `prefixed_custom_parameters` appropriately prefixes the configured custom params + """ + self.xblock.custom_parameters = ['param_1=true', 'param_2 = false', 'lti_version=1.1'] + params = self.xblock.prefixed_custom_parameters + + self.assertEqual(params, {u'custom_param_1': u'true', u'custom_param_2': u'false', u'lti_version': u'1.1'}) + + def test_invalid_custom_parameter(self): + """ + Test `prefixed_custom_parameters` when a custom parameter has been configured with the wrong format + """ + self.xblock.custom_parameters = ['param_1=true', 'param_2=false', 'lti_version1.1'] + + with self.assertRaises(LtiError): + __ = self.xblock.prefixed_custom_parameters + + def test_is_past_due_no_due_date(self): + """ + Test `is_past_due` is False when there is no due date + """ + self.xblock.due = None + self.xblock.graceperiod = timedelta(days=1) + + self.assertFalse(self.xblock.is_past_due) + + def test_is_past_due_with_graceperiod(self): + """ + Test `is_past_due` when a graceperiod has been defined + """ + now = datetime.utcnow() + self.xblock.graceperiod = timedelta(days=1) + + self.xblock.due = now + self.assertFalse(self.xblock.is_past_due) + + self.xblock.due = now - timedelta(days=2) + self.assertTrue(self.xblock.is_past_due) + + def test_is_past_due_no_graceperiod(self): + """ + Test `is_past_due` when no graceperiod has been defined + """ + now = datetime.utcnow() + self.xblock.graceperiod = None + + self.xblock.due = now - timedelta(days=1) + self.assertTrue(self.xblock.is_past_due) + + self.xblock.due = now + timedelta(days=1) + self.assertFalse(self.xblock.is_past_due) + + +class TestStudentView(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock.student_view() + """ + + def test_has_score_false(self): + """ + Test `has_score` is True + """ + self.xblock.has_score = False + fragment = self.xblock.student_view({}) + + self.assertNotIn(HTML_PROBLEM_PROGRESS, fragment.content) + + def test_has_score_true(self): + """ + Test `has_score` is True and `weight` has been configured + """ + self.xblock.has_score = True + fragment = self.xblock.student_view({}) + + self.assertIn(HTML_PROBLEM_PROGRESS, fragment.content) + + def test_launch_target_iframe(self): + """ + Test when `launch_target` is iframe + """ + self.xblock.launch_target = 'iframe' + fragment = self.xblock.student_view({}) + + self.assertNotIn(HTML_LAUNCH_MODAL_BUTTON, fragment.content) + self.assertNotIn(HTML_LAUNCH_NEW_WINDOW_BUTTON, fragment.content) + self.assertIn(HTML_IFRAME, fragment.content) + + def test_launch_target_modal(self): + """ + Test when `launch_target` is modal + """ + self.xblock.launch_target = 'modal' + fragment = self.xblock.student_view({}) + + self.assertIn(HTML_LAUNCH_MODAL_BUTTON, fragment.content) + self.assertNotIn(HTML_LAUNCH_NEW_WINDOW_BUTTON, fragment.content) + self.assertIn(HTML_IFRAME, fragment.content) + + def test_launch_target_new_window(self): + """ + Test when `launch_target` is iframe + """ + self.xblock.launch_target = 'new_window' + fragment = self.xblock.student_view({}) + + self.assertIn(HTML_LAUNCH_NEW_WINDOW_BUTTON, fragment.content) + self.assertNotIn(HTML_LAUNCH_MODAL_BUTTON, fragment.content) + self.assertNotIn(HTML_IFRAME, fragment.content) + + def test_no_launch_url(self): + """ + Test `launch_url` has not been configured + """ + self.xblock.launch_url = '' + fragment = self.xblock.student_view({}) + + self.assertIn(HTML_ERROR_MESSAGE, fragment.content) + + def test_no_launch_url_hide_launch_true(self): + """ + Test `launch_url` has not been configured and `hide_launch` is True + """ + self.xblock.launch_url = '' + self.xblock.hide_launch = True + fragment = self.xblock.student_view({}) + + self.assertNotIn(HTML_ERROR_MESSAGE, fragment.content) + + +class TestLtiLaunchHandler(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock.lti_launch_handler() + """ + + @patch('lti_consumer.lti.LtiConsumer.get_signed_lti_parameters') + def test_handle_request_called(self, mock_get_signed_lti_parameters): + """ + Test LtiConsumer.get_signed_lti_parameters is called and a 200 HTML response is returned + """ + request = make_request('', 'GET') + response = self.xblock.lti_launch_handler(request) + + assert mock_get_signed_lti_parameters.called + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'text/html') + + +class TestOutcomeServiceHandler(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock.outcome_service_handler() + """ + + @patch('lti_consumer.outcomes.OutcomeService.handle_request') + def test_handle_request_called(self, mock_handle_request): + """ + Test OutcomeService.handle_request is called and a 200 XML response is returned + """ + request = make_request('', 'POST') + response = self.xblock.outcome_service_handler(request) + + assert mock_handle_request.called + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/xml') + + +class TestResultServiceHandler(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock.result_service_handler() + """ + + def setUp(self): + super(TestResultServiceHandler, self).setUp() + self.lti_provider_key = 'test' + self.lti_provider_secret = 'secret' + self.xblock.runtime.debug = False + self.xblock.runtime.get_real_user = Mock() + self.xblock.accept_grades_past_due = True + + @patch('lti_consumer.lti_consumer.log_authorization_header') + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret') + def test_runtime_debug_true(self, mock_lti_provider_key_secret, mock_log_auth_header): + """ + Test `log_authorization_header` is called when runtime.debug is True + """ + mock_lti_provider_key_secret.__get__ = Mock(return_value=(self.lti_provider_key, self.lti_provider_secret)) + self.xblock.runtime.debug = True + request = make_request('', 'GET') + self.xblock.result_service_handler(request) + + mock_log_auth_header.assert_called_with(request, self.lti_provider_key, self.lti_provider_secret) + + @patch('lti_consumer.lti_consumer.log_authorization_header') + def test_runtime_debug_false(self, mock_log_auth_header): + """ + Test `log_authorization_header` is not called when runtime.debug is False + """ + self.xblock.runtime.debug = False + self.xblock.result_service_handler(make_request('', 'GET')) + + assert not mock_log_auth_header.called + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.is_past_due') + def test_accept_grades_past_due_false_and_is_past_due_true(self, mock_is_past_due): + """ + Test 404 response returned when `accept_grades_past_due` is False + and `is_past_due` is True + """ + mock_is_past_due.__get__ = Mock(return_value=True) + self.xblock.accept_grades_past_due = False + response = self.xblock.result_service_handler(make_request('', 'GET')) + + self.assertEqual(response.status_code, 404) + + @patch('lti_consumer.lti.LtiConsumer.get_result') + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.is_past_due') + def test_accept_grades_past_due_true_and_is_past_due_true(self, mock_is_past_due, mock_parse_suffix, + mock_get_result): + """ + Test 200 response returned when `accept_grades_past_due` is True and `is_past_due` is True + """ + mock_is_past_due.__get__ = Mock(return_value=True) + mock_parse_suffix.return_value = FAKE_USER_ID + mock_get_result.return_value = {} + response = self.xblock.result_service_handler(make_request('', 'GET')) + + self.assertEqual(response.status_code, 200) + + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_parse_suffix_raises_error(self, mock_parse_suffix): + """ + Test 404 response returned when the user id cannot be parsed from the request path suffix + """ + mock_parse_suffix.side_effect = LtiError() + response = self.xblock.result_service_handler(make_request('', 'GET')) + + self.assertEqual(response.status_code, 404) + + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers') + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_verify_headers_raises_error(self, mock_parse_suffix, mock_verify_result_headers): + """ + Test 401 response returned when `verify_result_headers` raises LtiError + """ + mock_parse_suffix.return_value = FAKE_USER_ID + mock_verify_result_headers.side_effect = LtiError() + response = self.xblock.result_service_handler(make_request('', 'GET')) + + self.assertEqual(response.status_code, 401) + + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_bad_user_id(self, mock_parse_suffix): + """ + Test 404 response returned when a user cannot be found + """ + mock_parse_suffix.return_value = FAKE_USER_ID + self.xblock.runtime.get_real_user.return_value = None + response = self.xblock.result_service_handler(make_request('', 'GET')) + + self.assertEqual(response.status_code, 404) + + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_bad_request_method(self, mock_parse_suffix): + """ + Test 404 response returned when the request contains an unsupported method + """ + mock_parse_suffix.return_value = FAKE_USER_ID + response = self.xblock.result_service_handler(make_request('', 'POST')) + + self.assertEqual(response.status_code, 404) + + @patch('lti_consumer.lti.LtiConsumer.get_result') + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_get_result_raises_error(self, mock_parse_suffix, mock_get_result): + """ + Test 404 response returned when the LtiConsumer result service handler methods raise an exception + """ + mock_parse_suffix.return_value = FAKE_USER_ID + mock_get_result.side_effect = LtiError() + response = self.xblock.result_service_handler(make_request('', 'GET')) + + self.assertEqual(response.status_code, 404) + + @patch('lti_consumer.lti.LtiConsumer.get_result') + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_get_result_called(self, mock_parse_suffix, mock_get_result): + """ + Test 200 response and LtiConsumer.get_result is called on a GET request + """ + mock_parse_suffix.return_value = FAKE_USER_ID + mock_get_result.return_value = {} + response = self.xblock.result_service_handler(make_request('', 'GET')) + + assert mock_get_result.called + self.assertEqual(response.status_code, 200) + + @patch('lti_consumer.lti.LtiConsumer.put_result') + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_put_result_called(self, mock_parse_suffix, mock_put_result): + """ + Test 200 response and LtiConsumer.put_result is called on a PUT request + """ + mock_parse_suffix.return_value = FAKE_USER_ID + mock_put_result.return_value = {} + response = self.xblock.result_service_handler(make_request('', 'PUT')) + + assert mock_put_result.called + self.assertEqual(response.status_code, 200) + + @patch('lti_consumer.lti.LtiConsumer.delete_result') + @patch('lti_consumer.lti.LtiConsumer.verify_result_headers', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.parse_handler_suffix') + def test_delete_result_called(self, mock_parse_suffix, mock_delete_result): + """ + Test 200 response and LtiConsumer.delete_result is called on a DELETE request + """ + mock_parse_suffix.return_value = FAKE_USER_ID + mock_delete_result.return_value = {} + response = self.xblock.result_service_handler(make_request('', 'DELETE')) + + assert mock_delete_result.called + self.assertEqual(response.status_code, 200) + + +class TestMaxScore(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock.max_score() + """ + + def test_max_score_when_scored(self): + """ + Test `max_score` when has_score is True + """ + self.xblock.has_score = True + self.xblock.weight = 1.0 + + self.assertEqual(self.xblock.max_score(), 1.0) + + def test_max_score_when_not_scored(self): + """ + Test `max_score` when has_score is False + """ + self.xblock.has_score = False + self.xblock.weight = 1.0 + + self.assertIsNone(self.xblock.max_score()) + + +class TestSetScore(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock.set_user_module_score() and LtiConsumerXBlock.clear_user_module_score() + """ + + def test_rebind_called(self): + """ + Test that `runtime.rebind_noauth_module_to_user` is called + """ + user = Mock(user_id=FAKE_USER_ID) + self.xblock.set_user_module_score(user, 0.92, 1.0, 'Great Job!') + + self.xblock.runtime.rebind_noauth_module_to_user.assert_called_with(self.xblock, user) + + def test_publish_grade_event_called(self): + """ + Test that `runtime.publish` is called + """ + user = Mock(id=FAKE_USER_ID) + score = 0.92 + max_score = 1.0 + self.xblock.set_user_module_score(user, score, max_score) + + self.xblock.runtime.publish.assert_called_with(self.xblock, 'grade', { + 'value': score, + 'max_value': max_score, + 'user_id': FAKE_USER_ID + }) + + def test_score_is_none(self): + """ + Test when score parameter is None + """ + max_score = 1.0 + user = Mock(id=FAKE_USER_ID) + self.xblock.set_user_module_score(user, None, max_score) + + self.assertEqual(self.xblock.module_score, None) + + def test_max_score_is_none(self): + """ + Test when max_score parameter is None + """ + user = Mock(id=FAKE_USER_ID) + self.xblock.set_user_module_score(user, 0.92, None) + + self.assertEqual(self.xblock.module_score, None) + + def test_score_and_max_score_populated(self): + """ + Test when both score and max_score parameters are not None + """ + user = Mock(id=FAKE_USER_ID) + score = 0.92 + max_score = 1.0 + self.xblock.set_user_module_score(user, score, max_score) + + self.assertEqual(self.xblock.module_score, score * max_score) + + def test_no_comment_param(self): + """ + Test when no comment parameter is passed + """ + self.xblock.set_user_module_score(Mock(), 0.92, 1.0) + + self.assertEqual(self.xblock.score_comment, '') + + def test_comment_param(self): + """ + Test when comment parameter is passed + """ + comment = 'Great Job!' + self.xblock.set_user_module_score(Mock(), 0.92, 1.0, comment) + + self.assertEqual(self.xblock.score_comment, comment) + + @patch('lti_consumer.LtiConsumerXBlock.set_user_module_score') + def test_clear_user_module_score(self, mock_set_user_module_score): + """ + Test that clear_user_module_score calls set_user_module_score with params set to None + """ + user = Mock() + self.xblock.clear_user_module_score(user) + mock_set_user_module_score.assert_called_with(user, None, None) + + +class TestParseSuffix(TestLtiConsumerXBlock): + """ + Unit tests for parse_handler_suffix() + """ + + def test_empty_suffix(self): + """ + Test `parse_handler_suffix` when `suffix` parameter is an empty string + """ + with self.assertRaises(LtiError): + parse_handler_suffix("") + + def test_suffix_no_match(self): + """ + Test `parse_handler_suffix` when `suffix` cannot be parsed + """ + with self.assertRaises(LtiError): + parse_handler_suffix("bogus_path/4") + + def test_suffix_match(self): + """ + Test `parse_handler_suffix` when `suffix` parameter can be parsed + :return: + """ + parsed = parse_handler_suffix("user/{}".format(FAKE_USER_ID)) + self.assertEqual(parsed, FAKE_USER_ID) + + +class TestGetContext(TestLtiConsumerXBlock): + """ + Unit tests for LtiConsumerXBlock._get_context_for_template() + """ + + def test_context_keys(self): + """ + Test `_get_context_for_template` returns dict with correct keys + """ + context_keys = ( + 'launch_url', 'element_id', 'element_class', 'launch_target', 'display_name', 'form_url', 'hide_launch', + 'has_score', 'weight', 'module_score', 'comment', 'description', 'ask_to_send_username', + 'ask_to_send_email', 'button_text', 'modal_height', 'modal_width', 'accept_grades_past_due' + ) + context = self.xblock._get_context_for_template() # pylint: disable=protected-access + + for key in context_keys: + self.assertIn(key, context) diff --git a/lti_consumer/tests/unit/test_oauth.py b/lti_consumer/tests/unit/test_oauth.py new file mode 100644 index 0000000000000000000000000000000000000000..57e98cbcd6853b0fdacaff9f6d0c28617c5a0465 --- /dev/null +++ b/lti_consumer/tests/unit/test_oauth.py @@ -0,0 +1,100 @@ +""" +Unit tests for lti_consumer.oauth module +""" + +import unittest + +from mock import Mock, patch + +from lti_consumer.tests.unit.test_utils import make_request + +from lti_consumer.exceptions import LtiError +from lti_consumer.oauth import ( + get_oauth_request_signature, + verify_oauth_body_signature, + log_authorization_header, +) + + +OAUTH_PARAMS = [ + (u'oauth_nonce', u'80966668944732164491378916897'), + (u'oauth_timestamp', u'1378916897'), + (u'oauth_version', u'1.0'), + (u'oauth_signature_method', u'HMAC-SHA1'), + (u'oauth_consumer_key', u'test'), + (u'oauth_signature', u'frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D'), +] +OAUTH_PARAMS_WITH_BODY_HASH = OAUTH_PARAMS + [(u'oauth_body_hash', u'2jmj7l5rSw0yVb/vlWAYkK/YBwk=')] + + +class TestGetOauthRequestSignature(unittest.TestCase): + """ + Unit tests for `lti_consumer.oauth.get_oauth_request_signature` + """ + + @patch('oauthlib.oauth1.Client.sign') + def test_auth_header_returned(self, mock_client_sign): + """ + Test that the correct Authorization header is returned + """ + mock_client_sign.return_value = '', {'Authorization': ''}, '' + signature = get_oauth_request_signature('test', 'secret', '', {}, '') + + mock_client_sign.assert_called_with('', http_method=u'POST', body='', headers={}) + self.assertEqual(signature, '') + + @patch('oauthlib.oauth1.Client.sign') + def test_sign_raises_error(self, mock_client_sign): + """ + Test that the correct Authorization header is returned + """ + mock_client_sign.side_effect = ValueError + + with self.assertRaises(LtiError): + __ = get_oauth_request_signature('test', 'secret', '', {}, '') + + +class TestVerifyOauthBodySignature(unittest.TestCase): + """ + Unit tests for `lti_consumer.oauth.verify_oauth_body_signature` + """ + + @patch('oauthlib.oauth1.rfc5849.signature.verify_hmac_sha1', Mock(return_value=True)) + @patch('oauthlib.oauth1.rfc5849.signature.collect_parameters', Mock(return_value=OAUTH_PARAMS_WITH_BODY_HASH)) + def test_valid_signature(self): + """ + Test True is returned when the request signature is valid + """ + self.assertTrue(verify_oauth_body_signature(make_request(''), 'test', 'secret')) + + @patch('oauthlib.oauth1.rfc5849.signature.verify_hmac_sha1', Mock(return_value=False)) + @patch('oauthlib.oauth1.rfc5849.signature.collect_parameters', Mock(return_value=OAUTH_PARAMS_WITH_BODY_HASH)) + def test_invalid_signature(self): + """ + Test exception is raised when the request signature is invalid + """ + with self.assertRaises(LtiError): + verify_oauth_body_signature(make_request(''), 'test', 'secret') + + @patch('oauthlib.oauth1.rfc5849.signature.verify_hmac_sha1', Mock(return_value=False)) + @patch('oauthlib.oauth1.rfc5849.signature.collect_parameters', Mock(return_value=OAUTH_PARAMS)) + def test_missing_oauth_body_hash(self): + """ + Test exception is raised when the request signature is missing oauth_body_hash + """ + with self.assertRaises(LtiError): + verify_oauth_body_signature(make_request(''), 'test', 'secret') + + +class TestLogCorrectAuthorizationHeader(unittest.TestCase): + """ + Unit tests for `lti_consumer.oauth.log_authorization_header` + """ + + @patch('lti_consumer.oauth.log') + def test_log_auth_header(self, mock_log): + """ + Test that log.debug is called + """ + log_authorization_header(make_request(''), 'test', 'secret') + self.assertTrue(mock_log.debug.called) diff --git a/lti_consumer/tests/unit/test_outcomes.py b/lti_consumer/tests/unit/test_outcomes.py new file mode 100644 index 0000000000000000000000000000000000000000..ad73dbf50c50d153a1c9b37a65d9fa8a1bc04762 --- /dev/null +++ b/lti_consumer/tests/unit/test_outcomes.py @@ -0,0 +1,385 @@ +""" +Unit tests for lti_consumer.outcomes module +""" + +import unittest +import textwrap + +from copy import copy +from mock import Mock, PropertyMock, patch + +from lti_consumer.tests.unit.test_utils import make_request +from lti_consumer.tests.unit.test_lti_consumer import TestLtiConsumerXBlock + +from lti_consumer.outcomes import parse_grade_xml_body, OutcomeService +from lti_consumer.exceptions import LtiError + + +REQUEST_BODY_TEMPLATE_VALID = textwrap.dedent(""" + <?xml version="1.0" encoding="UTF-8"?> + <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXRequestHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>{msg_id}</imsx_messageIdentifier> + </imsx_POXRequestHeaderInfo> + </imsx_POXHeader> + <imsx_POXBody> + <{action}> + <resultRecord> + <sourcedGUID> + <sourcedId>{sourced_id}</sourcedId> + </sourcedGUID> + <result> + <resultScore> + <language>en-us</language> + <textString>{score}</textString> + </resultScore> + </result> + </resultRecord> + </{action}> + </imsx_POXBody> + </imsx_POXEnvelopeRequest> +""") + +REQUEST_BODY_TEMPLATE_MISSING_MSG_ID = textwrap.dedent(""" + <?xml version="1.0" encoding="UTF-8"?> + <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXRequestHeaderInfo> + <imsx_version>V1.0</imsx_version> + </imsx_POXRequestHeaderInfo> + </imsx_POXHeader> + </imsx_POXEnvelopeRequest> +""") + +REQUEST_BODY_TEMPLATE_MISSING_SOURCED_ID = textwrap.dedent(""" + <?xml version="1.0" encoding="UTF-8"?> + <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXRequestHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>{msg_id}</imsx_messageIdentifier> + </imsx_POXRequestHeaderInfo> + </imsx_POXHeader> + <imsx_POXBody> + <{action}> + <resultRecord> + <sourcedGUID> + </sourcedGUID> + </resultRecord> + </{action}> + </imsx_POXBody> + </imsx_POXEnvelopeRequest> +""") + +REQUEST_BODY_TEMPLATE_MISSING_BODY = textwrap.dedent(""" + <?xml version="1.0" encoding="UTF-8"?> + <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXRequestHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>{msg_id}</imsx_messageIdentifier> + </imsx_POXRequestHeaderInfo> + </imsx_POXHeader> + </imsx_POXEnvelopeRequest> +""") + +REQUEST_BODY_TEMPLATE_MISSING_ACTION = textwrap.dedent(""" + <?xml version="1.0" encoding="UTF-8"?> + <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXRequestHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>{msg_id}</imsx_messageIdentifier> + </imsx_POXRequestHeaderInfo> + </imsx_POXHeader> + <imsx_POXBody> + </imsx_POXBody> + </imsx_POXEnvelopeRequest> +""") + +REQUEST_BODY_TEMPLATE_MISSING_SCORE = textwrap.dedent(""" + <?xml version="1.0" encoding="UTF-8"?> + <imsx_POXEnvelopeRequest xmlns="http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXRequestHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>{msg_id}</imsx_messageIdentifier> + </imsx_POXRequestHeaderInfo> + </imsx_POXHeader> + <imsx_POXBody> + <{action}> + <resultRecord> + <sourcedGUID> + <sourcedId>{sourced_id}</sourcedId> + </sourcedGUID> + <result> + <resultScore> + <language>en-us</language> + </resultScore> + </result> + </resultRecord> + </{action}> + </imsx_POXBody> + </imsx_POXEnvelopeRequest> +""") + +REQUEST_TEMPLATE_DEFAULTS = { + 'msg_id': '528243ba5241b', + 'sourced_id': 'lti_provider:localhost-i4x-2-3-lti-31de800015cf4afb973356dbe81496df:4xk1kn', + 'score': 0.5, + 'action': 'replaceResultRequest', +} + +RESPONSE_BODY_TEMPLATE = textwrap.dedent(""" + <?xml version="1.0" encoding="UTF-8"?> + <imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"> + <imsx_POXHeader> + <imsx_POXResponseHeaderInfo> + <imsx_version>V1.0</imsx_version> + <imsx_messageIdentifier>{msg_id}</imsx_messageIdentifier> + <imsx_statusInfo> + <imsx_codeMajor>{code}</imsx_codeMajor> + <imsx_severity>status</imsx_severity> + <imsx_description>{description}</imsx_description> + <imsx_messageRefIdentifier> + </imsx_messageRefIdentifier> + </imsx_statusInfo> + </imsx_POXResponseHeaderInfo> + </imsx_POXHeader> + <imsx_POXBody>{response}</imsx_POXBody> + </imsx_POXEnvelopeResponse> +""") + + +class TestParseGradeXmlBody(unittest.TestCase): + """ + Unit tests for `lti_consumer.outcomes.parse_grade_xml_body` + """ + + def test_valid_request_body(self): + """ + Test correct values returned on valid request body + """ + msg_id, sourced_id, score, action = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_VALID.format(**REQUEST_TEMPLATE_DEFAULTS) + ) + + self.assertEqual(msg_id, REQUEST_TEMPLATE_DEFAULTS['msg_id']) + self.assertEqual(sourced_id, REQUEST_TEMPLATE_DEFAULTS['sourced_id']) + self.assertEqual(score, REQUEST_TEMPLATE_DEFAULTS['score']) + self.assertEqual(action, REQUEST_TEMPLATE_DEFAULTS['action']) + + def test_lower_score_boundary(self): + """ + Test correct values returned on valid request body with a + score that matches the lower boundary of allowed scores + """ + data = copy(REQUEST_TEMPLATE_DEFAULTS) + data['score'] = 0.0 + + msg_id, sourced_id, score, action = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_VALID.format(**data) + ) + + self.assertEqual(msg_id, data['msg_id']) + self.assertEqual(sourced_id, data['sourced_id']) + self.assertEqual(score, data['score']) + self.assertEqual(action, data['action']) + + def test_upper_score_boundary(self): + """ + Test correct values returned on valid request body with a + score that matches the upper boundary of allowed scores + """ + data = copy(REQUEST_TEMPLATE_DEFAULTS) + data['score'] = 1.0 + + msg_id, sourced_id, score, action = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_VALID.format(**data) + ) + + self.assertEqual(msg_id, data['msg_id']) + self.assertEqual(sourced_id, data['sourced_id']) + self.assertEqual(score, data['score']) + self.assertEqual(action, data['action']) + + def test_missing_msg_id(self): + """ + Test missing <imsx_messageIdentifier> raises LtiError + """ + with self.assertRaises(LtiError): + __, __, __, __ = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_MISSING_MSG_ID.format(**REQUEST_TEMPLATE_DEFAULTS) + ) + + def test_missing_sourced_id(self): + """ + Test missing <sourcedId> raises LtiError + """ + with self.assertRaises(LtiError): + __, __, __, __ = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_MISSING_SOURCED_ID.format(**REQUEST_TEMPLATE_DEFAULTS) + ) + + def test_missing_body(self): + """ + Test missing <imsx_POXBody> raises LtiError + """ + with self.assertRaises(LtiError): + __, __, __, __ = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_MISSING_BODY.format(**REQUEST_TEMPLATE_DEFAULTS) + ) + + def test_missing_action(self): + """ + Test missing <replaceResultRequest> raises LtiError + """ + with self.assertRaises(LtiError): + __, __, __, __ = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_MISSING_ACTION.format(**REQUEST_TEMPLATE_DEFAULTS) + ) + + def test_missing_score(self): + """ + Test missing score <textString> raises LtiError + """ + with self.assertRaises(LtiError): + __, __, __, __ = parse_grade_xml_body( + REQUEST_BODY_TEMPLATE_MISSING_SCORE.format(**REQUEST_TEMPLATE_DEFAULTS) + ) + + def test_score_outside_range(self): + """ + Test score outside the range raises exception + """ + data = copy(REQUEST_TEMPLATE_DEFAULTS) + + with self.assertRaises(LtiError): + data['score'] = 10 + __, __, __, __ = parse_grade_xml_body(REQUEST_BODY_TEMPLATE_VALID.format(**data)) + + with self.assertRaises(LtiError): + data['score'] = -10 + __, __, __, __ = parse_grade_xml_body(REQUEST_BODY_TEMPLATE_VALID.format(**data)) + + def test_invalid_score(self): + """ + Test non-float score raises exception + """ + data = copy(REQUEST_TEMPLATE_DEFAULTS) + data['score'] = '1,0' + + with self.assertRaises(Exception): + __, __, __, __ = parse_grade_xml_body(REQUEST_BODY_TEMPLATE_VALID.format(**data)) + + def test_empty_xml(self): + """ + Test empty xml raises exception + """ + with self.assertRaises(LtiError): + __, __, __, __ = parse_grade_xml_body('') + + def test_invalid_xml(self): + """ + Test invalid xml raises exception + """ + with self.assertRaises(LtiError): + __, __, __, __ = parse_grade_xml_body('<xml>') + + +class TestOutcomeService(TestLtiConsumerXBlock): + """ + Unit tests for OutcomeService + """ + + def setUp(self): + super(TestOutcomeService, self).setUp() + self.outcome_servce = OutcomeService(self.xblock) + + @patch('lti_consumer.outcomes.verify_oauth_body_signature', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + @patch('lti_consumer.outcomes.parse_grade_xml_body', Mock(return_value=('', '', 0.5, 'replaceResultRequest'))) + def test_handle_replace_result_success(self): + """ + Test replace result request returns with success indicator + """ + request = make_request('') + values = { + 'code': 'success', + 'description': 'Score for is now 0.5', + 'msg_id': '', + 'response': '<replaceResultResponse/>' + } + + self.assertEqual( + self.outcome_servce.handle_request(request).strip(), + RESPONSE_BODY_TEMPLATE.format(**values).strip() + ) + + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.is_past_due', PropertyMock(return_value=True)) + def test_grade_past_due(self): + """ + Test late grade returns failure response + """ + request = make_request('') + self.xblock.accept_grades_past_due = False + response = self.outcome_servce.handle_request(request) + + self.assertIn('failure', response) + self.assertIn('Grade is past due', response) + + @patch('lti_consumer.outcomes.parse_grade_xml_body') + def test_xml_parse_lti_error(self, mock_parse): + """ + Test XML parsing LtiError returns failure response + """ + request = make_request('') + + mock_parse.side_effect = LtiError + response = self.outcome_servce.handle_request(request) + self.assertIn('failure', response) + self.assertIn('Request body XML parsing error', response) + + @patch('lti_consumer.outcomes.verify_oauth_body_signature') + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + @patch('lti_consumer.outcomes.parse_grade_xml_body', Mock(return_value=('', '', 0.5, 'replaceResultRequest'))) + def test_invalid_signature(self, mock_verify): + """ + Test invalid oauth signature returns failure response + """ + request = make_request('') + + mock_verify.side_effect = ValueError + self.assertIn('failure', self.outcome_servce.handle_request(request)) + + mock_verify.side_effect = LtiError + self.assertIn('failure', self.outcome_servce.handle_request(request)) + + @patch('lti_consumer.outcomes.verify_oauth_body_signature', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + @patch('lti_consumer.outcomes.parse_grade_xml_body', Mock(return_value=('', '', 0.5, 'replaceResultRequest'))) + def test_user_not_found(self): + """ + Test user not found returns failure response + """ + request = make_request('') + self.xblock.runtime.get_real_user.return_value = None + response = self.outcome_servce.handle_request(request) + + self.assertIn('failure', response) + self.assertIn('User not found', response) + + @patch('lti_consumer.outcomes.verify_oauth_body_signature', Mock(return_value=True)) + @patch('lti_consumer.lti_consumer.LtiConsumerXBlock.lti_provider_key_secret', PropertyMock(return_value=('t', 's'))) + @patch('lti_consumer.outcomes.parse_grade_xml_body', Mock(return_value=('', '', 0.5, 'unsupportedRequest'))) + def test_unsupported_action(self): + """ + Test unsupported action returns unsupported response + """ + request = make_request('') + response = self.outcome_servce.handle_request(request) + + self.assertIn('unsupported', response) + self.assertIn('Target does not support the requested operation.', response) diff --git a/lti_consumer/tests/unit/test_utils.py b/lti_consumer/tests/unit/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..b309edc6a83dde8341fcf1bf6d85c4f23c89f475 --- /dev/null +++ b/lti_consumer/tests/unit/test_utils.py @@ -0,0 +1,56 @@ +""" +Utility functions used within unit tests +""" + +from webob import Request +from mock import Mock + +from xblock.fields import ScopeIds +from xblock.runtime import KvsFieldData, DictKeyValueStore + +from workbench.runtime import WorkbenchRuntime + + +FAKE_USER_ID = 'fake_user_id' + + +def make_xblock(xblock_name, xblock_cls, attributes): + """ + Helper to construct XBlock objects + """ + runtime = WorkbenchRuntime() + key_store = DictKeyValueStore() + db_model = KvsFieldData(key_store) + ids = generate_scope_ids(runtime, xblock_name) + xblock = xblock_cls(runtime, db_model, scope_ids=ids) + xblock.category = Mock() + xblock.location = Mock( + html_id=Mock(return_value='sample_element_id'), + ) + xblock.runtime = Mock( + hostname='localhost', + ) + xblock.course_id = 'course-v1:edX+DemoX+Demo_Course' + for key, value in attributes.iteritems(): + setattr(xblock, key, value) + return xblock + + +def generate_scope_ids(runtime, block_type): + """ + Helper to generate scope IDs for an XBlock + """ + def_id = runtime.id_generator.create_definition(block_type) + usage_id = runtime.id_generator.create_usage(def_id) + return ScopeIds('user', block_type, def_id, usage_id) + + +def make_request(body, method='POST'): + """ + Helper to make a request + """ + request = Request.blank('/') + request.method = 'POST' + request.body = body.encode('utf-8') + request.method = method + return request diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000000000000000000000000000000000000..f71f8c0b7f15d83790383017c2b6352dc97a2c96 --- /dev/null +++ b/pylintrc @@ -0,0 +1,156 @@ +# *************************** +# ** DO NOT EDIT THIS FILE ** +# *************************** +# +# It is generated by: +# $ edx_lint write pylintrc +# +# +# +# +# +# +# +# +# STAY AWAY! +# +# +# +# +# +# SERIOUSLY. +# +# ------------------------------ +[MASTER] +profile = no +ignore = +persistent = yes +load-plugins = edx_lint.pylint,pylint_django + +[MESSAGES CONTROL] +disable = + locally-disabled, + locally-enabled, + too-few-public-methods, + bad-builtin, + star-args, + abstract-class-not-used, + abstract-class-little-used, + no-init, + fixme, + too-many-lines, + no-self-use, + too-many-ancestors, + too-many-instance-attributes, + too-few-public-methods, + too-many-public-methods, + too-many-return-statements, + too-many-branches, + too-many-arguments, + too-many-locals, + unused-wildcard-import, + duplicate-code + +[REPORTS] +output-format = text +files-output = no +reports = no +evaluation = 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) +comment = no + +[BASIC] +required-attributes = +bad-functions = map,filter,apply,input +module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ +const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$ +class-rgx = [A-Z_][a-zA-Z0-9]+$ +function-rgx = ([a-z_][a-z0-9_]{2,30}|test_[a-z0-9_]+)$ +method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ +attr-rgx = [a-z_][a-z0-9_]{2,30}$ +argument-rgx = [a-z_][a-z0-9_]{2,30}$ +variable-rgx = [a-z_][a-z0-9_]{2,30}$ +class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ +inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$ +good-names = f,i,j,k,db,ex,Run,_,__ +bad-names = foo,bar,baz,toto,tutu,tata +no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$ +docstring-min-length = -1 + +[FORMAT] +max-line-length = 120 +ignore-long-lines = ^\s*(# )?<?https?://\S+>?$ +single-line-if-stmt = no +no-space-check = trailing-comma,dict-separator +max-module-lines = 1000 +indent-string = ' ' + +[MISCELLANEOUS] +notes = FIXME,XXX,TODO + +[SIMILARITIES] +min-similarity-lines = 4 +ignore-comments = yes +ignore-docstrings = yes +ignore-imports = no + +[TYPECHECK] +ignore-mixin-members = yes +ignored-classes = SQLObject +zope = no +unsafe-load-any-extension = yes +generated-members = + REQUEST, + acl_users, + aq_parent, + objects, + DoesNotExist, + can_read, + can_write, + get_url, + size, + content, + status_code, + create, + build, + fields, + tag, + org, + course, + category, + name, + revision, + _meta, + +[VARIABLES] +init-import = no +dummy-variables-rgx = _|dummy|unused|.*_unused +additional-builtins = + +[CLASSES] +ignore-iface-methods = isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by +defining-attr-methods = __init__,__new__,setUp +valid-classmethod-first-arg = cls +valid-metaclass-classmethod-first-arg = mcs + +[DESIGN] +max-args = 5 +ignored-argument-names = _.* +max-locals = 15 +max-returns = 6 +max-branches = 12 +max-statements = 50 +max-parents = 7 +max-attributes = 7 +min-public-methods = 2 +max-public-methods = 20 + +[IMPORTS] +deprecated-modules = regsub,TERMIOS,Bastion,rexec +import-graph = +ext-import-graph = +int-import-graph = + +[EXCEPTIONS] +overgeneral-exceptions = Exception + +# d8bfe98cfd638a8d10288d842a7d3d4532a279c3 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..b5f12f0f1cf0a7a9e43d5139a5116efc65926694 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +lxml==3.4.4 +bleach==1.4.2 +oauthlib==1.0.3 +mako==1.0.2 +git+https://github.com/edx/XBlock.git@xblock-0.4.1#egg=XBlock==0.4.1 +git+https://github.com/edx/xblock-utils.git@v1.0.0#egg=xblock-utils==v1.0.0 +-e . diff --git a/scripts/quality.sh b/scripts/quality.sh new file mode 100755 index 0000000000000000000000000000000000000000..a02788452271a587a039ad41e1539b7cb24d1c79 --- /dev/null +++ b/scripts/quality.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +pep8 --config=.pep8 lti_consumer +pylint --rcfile=pylintrc lti_consumer diff --git a/scripts/sass.sh b/scripts/sass.sh new file mode 100755 index 0000000000000000000000000000000000000000..35496b5a712770e40ffaa7b99a96547ce2071af9 --- /dev/null +++ b/scripts/sass.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +# Student view sass +sass --no-cache --style compressed ./lti_consumer/static/sass/student.scss ./lti_consumer/static/css/student.css diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000000000000000000000000000000000000..ea99b7d7ac2bcbeccb7d58c41ec8e1b234a6bd03 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +export DJANGO_SETTINGS_MODULE="workbench.settings" +mkdir -p var +rm -rf .coverage +nosetests --with-coverage --cover-package="lti_consumer" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..93a0dfd5360b74b7181843b33985c7d831d68f5f --- /dev/null +++ b/setup.py @@ -0,0 +1,47 @@ +"""Setup for lti_consumer XBlock.""" + +import os +from setuptools import setup + + +def package_data(pkg, roots): + """Generic function to find package_data. + + All of the files under each of the `roots` will be declared as package + data for package `pkg`. + + """ + data = [] + for root in roots: + for dirname, __, files in os.walk(os.path.join(pkg, root)): + for fname in files: + data.append(os.path.relpath(os.path.join(dirname, fname), pkg)) + + return {pkg: data} + + +setup( + name='lti_consumer-xblock', + version='v1.0.0', + description='This XBlock implements the consumer side of the LTI specification.', + packages=[ + 'lti_consumer', + ], + install_requires=[ + 'lxml==3.4.4', + 'bleach==1.4.2', + 'oauthlib==1.0.3', + 'mako==1.0.2', + 'XBlock==0.4.1', + 'xblock-utils==v1.0.0', + ], + dependency_links=[ + 'https://github.com/edx/xblock-utils/tarball/c39bf653e4f27fb3798662ef64cde99f57603f79#egg=xblock-utils', + ], + entry_points={ + 'xblock.v1': [ + 'lti_consumer = lti_consumer:LtiConsumerXBlock', + ] + }, + package_data=package_data("lti_consumer", ["static", "templates", "public"]), +) diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..5ae99aea0d41d47d4ec6db3fa30777f996a0893f --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,8 @@ +-r requirements.txt + +astroid==1.3.8 # Pinning to avoid backwards incompatibility issue with pylint/pylint-django +coveralls +pep8 +git+https://github.com/edx/django-pyfs.git@1.0.3#egg=django-pyfs==1.0.3 +git+https://github.com/edx/edx-lint.git@v0.3.2#egg=edx_lint==0.3.2 +git+https://github.com/edx/xblock-sdk.git#egg=xblock-sdk