diff --git a/.github/ISSUE_TEMPLATE/issue_template.yml b/.github/ISSUE_TEMPLATE/issue_template.yml new file mode 100644 index 00000000..5332a903 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue_template.yml @@ -0,0 +1,127 @@ +name: Issue Template +about: Create a new issue +title: "" + +body: + - type: select + id: issue_type + attributes: + label: Issue Type + description: Please select the type of issue you are creating. + options: + - Bug Report + - Feature Request + - General Issue + + - type: conditional + attributes: + conditions: + - field: issue_type + value: Bug Report + items: + - type: textarea + id: bug_description + attributes: + label: Bug Description + description: Provide a general description of the bug. + + - type: textarea + id: expected_behavior + attributes: + label: Expected Behavior + description: Describe the software behaviour that you would expect. This helps us identify whether this is actually a bug or if the app is working as intended. + + - type: enum + id: reproduction_steps + attributes: + label: Reproduction Steps + description: | + Enumerate the steps to reproduce the bug. + 1. Step 1 + 2. Step 2 + 3. Step 3 + 4. ... + + - type: text + id: browser + attributes: + label: Browser and Version + description: Specify the browser you are using. Please include the version. + + - type: select + id: priority + attributes: + label: Priority + description: Select the priority of the issue. + options: + - High + - Medium + - Low + + actions: + - type: add_label + name: Prio:HIGH + if: ${{ inputs.priority == 'High' }} + description: Adds 'Prio:High' label to the issue. + - type: add_label + name: Prio:MEDIUM + if: ${{ inputs.priority == 'Medium' }} + description: Adds 'Prio:Medium' label to the issue. + - type: add_label + name: Prio:LOW + if: ${{ inputs.priority == 'Low' }} + description: Adds 'Prio:Low' label to the issue. + + - type: conditional + attributes: + conditions: + - field: issue_type + value: Feature Request + items: + - type: textarea + id: feature_description + attributes: + label: Feature Description + description: Provide a general description of the feature. + + - type: text + id: user_role + attributes: + label: User Role + description: Describe your role in relation to this feature. + + - type: select + id: priority + attributes: + label: Priority + description: Select the priority of the feature request. + options: + - High + - Medium + - Low + + actions: + - type: add_label + name: Prio:HIGH + if: ${{ inputs.priority == 'High' }} + description: Adds 'Prio:High' label to the issue. + - type: add_label + name: Prio:MEDIUM + if: ${{ inputs.priority == 'Medium' }} + description: Adds 'Prio:Medium' label to the issue. + - type: add_label + name: Prio:LOW + if: ${{ inputs.priority == 'Low' }} + description: Adds 'Prio:Low' label to the issue. + + - type: conditional + attributes: + conditions: + - field: issue_type + value: General Issue + items: + - type: textarea + id: general_description + attributes: + label: General Issue Description + description: Here is room for your input if you are not sure whether your issue is Bug or a Feature Request. Please provide a detailed description of the issue and add labels if necessary. diff --git a/.github/workflows/build-backend.yml b/.github/workflows/build-backend.yml new file mode 100644 index 00000000..d5322edd --- /dev/null +++ b/.github/workflows/build-backend.yml @@ -0,0 +1,40 @@ +name: Backend Build + +on: + push: + branches: + - dev + + pull_request: + branches: + - dev + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Set environment variables + run: | + echo "SECRET_KEY=mock_secret_key_for_django" >> $GITHUB_ENV + + - name: Run tests + run: python ./src/manage.py test \ No newline at end of file diff --git a/.github/workflows/build-frontend.yml b/.github/workflows/build-frontend.yml new file mode 100644 index 00000000..c80d6155 --- /dev/null +++ b/.github/workflows/build-frontend.yml @@ -0,0 +1,31 @@ +name: Build frontend workflow + +on: + push: + branches: + - dev + pull_request: + branches: + - dev + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Install NodeJS + uses: actions/setup-node@v3 + with: + node-version: '16.18.1' + + - name: Install Dependencies + run: yarn install + + - name: Build App + run: yarn build \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index d211d142..8823596b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,4 @@ - - - [submodule "backend/src/co2calculator"] path = backend/src/co2calculator url = https://github.com/pledge4future/co2calculator.git + branch = dev \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 6d37b602..0089314e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,74 @@ -# Pledge4Future WebApp +# Pledge4Future App -The [pledge4future app](https://pledge4future.org) allows you to calculate your work related CO2e emissions from heating and electricity consumptions as well as business trips and commuting. The methodology for the calculation the emissions is implemented in the [co2calculator package](https://github.com/pledge4future/co2calculator). +Pledge4Future is a project to help you and your working group to measure and reduce your work-related CO2e emissions. + +The [pledge4future app](https://pledge4future.org) allows you to calculate your work related CO2e emissions from heating and electricity consumptions as well as business trips and commuting. The methodology for the calculation of the emissions is implemented in the [co2calculator package](https://github.com/pledge4future/co2calculator). + +Check out the [demo emission dashboard](https://pledge4future.org/dashboard)! + +### Installation + +This is a dockerized app which uses React in the frontend and Python, Django and GraphQL in the backend. + +### 1. Clone repository + +``` +git clone +cd WePledge +``` + +### 2. Load the submodules + +``` +git submodule update --init --recursive +``` + +### 3. Run docker + +``` +docker compose up +``` + +This will start the following services on your computer: + +Frontend: [http://localhost:3000](http://localhost:3000) +Backend: [http://localhost:8000](http://localhost:8000) +Django Admin: [http://localhost:8000/admin](http://localhost:8000/admin) +GraphQL API: [http://localhost:8000/graphql](http://localhost:8000/graphql) + +Refer to the [wiki](https://github.com/pledge4future/WePledge/wiki) for detailed instructions on how to run, adapt and debug the app. + +## Contribution guidelines + +We're always happy about new people contributing to our project! + +- If you encounter problems with the app, feel free to create an [issue in this repository](https://github.com/pledge4future/WePledge/issues). +- If you can fix it yourself, please create a new branch from 'dev', add your changes and once you're done create a pull request. +- If you would like to become a regular contributor to the project, please contact us at [info@pledge4future.org](mailto:info@pledge4future.org). + +## License + +This project is licensed under the [GPL-3.0 License](./LICENSE). + +## Acknowledgments + +We are supported by + +- [Goethe Institute](https://www.goethe.de) +- [HeiGIT gGmbH (Heidelberg Institute for Geoinformation Technology)](https://heigit.org/) +- [openrouteservice](https://openrouteservice.org/) +- [GIScience Research Group, Institute of Geography at Heidelberg +University](https://www.geog.uni-heidelberg.de/giscience.html) +- [Scientists4Future Heidelberg](https://heidelberg.scientists4future.org/) + + + + + + + + + + +
-Check out a preview under [this link](https://pledge4future.org). diff --git a/backend/.dockerignore b/backend/.dockerignore index f564f845..cc515ffa 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -8,4 +8,4 @@ Dockerfile *ordering_models.png */wait-for.sh */.devcontainer.json -**/.assets \ No newline at end of file +**/.assets diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml new file mode 100644 index 00000000..88e288fe --- /dev/null +++ b/backend/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +repos: + - repo: https://github.com/ambv/black + rev: 21.12b0 + hooks: + - id: black + language_version: python3.9 + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + args: + - "--max-line-length=79" + - "--max-complexity=18" + - "--ignore=E501,W503,E203,F401,D400,D205,D401,D202,T001,D105,C901,B010" + additional_dependencies: + [ + "flake8-bugbear==19.8.0", + "flake8-coding==1.3.2", + "flake8-comprehensions==3.0.1", + "flake8-debugger==3.2.1", + "flake8-deprecated==1.3", + "flake8-pep3101==1.2.1", + "flake8-polyfill==1.0.2", + "flake8-print==3.1.4", + "flake8-string-format==0.2.3", + "flake8-docstrings==1.5.0", + ] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: forbid-new-submodules + - id: mixed-line-ending + args: [ '--fix=lf' ] + description: Forces to replace line ending by the UNIX 'lf' character. + - id: pretty-format-json + args: [ '--no-sort-keys' ] + - id: no-commit-to-branch + args: [ --branch, master ] + - id: no-commit-to-branch + args: [ --branch, main ] diff --git a/backend/Dockerfile b/backend/Dockerfile index c8eaaa30..39c4e4df 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,8 +2,10 @@ FROM python:3.9 RUN apt-get update \ && apt-get install -y --no-install-recommends \ - postgresql-client \ + postgresql-client graphviz \ && rm -rf /var/lib/apt/lists/* && adduser --disabled-password python +RUN addgroup --gid 1006 developers \ + && usermod -a -G developers python USER python RUN mkdir ~/app && mkdir ~/.vscode-server WORKDIR /home/python/app diff --git a/backend/assets/database_structure.png b/backend/assets/database_structure.png new file mode 100644 index 00000000..cda1ed9d Binary files /dev/null and b/backend/assets/database_structure.png differ diff --git a/backend/docs/README.md b/backend/docs/README.md new file mode 100644 index 00000000..9e5409e6 --- /dev/null +++ b/backend/docs/README.md @@ -0,0 +1,84 @@ +# Backend documentation + +## Load submodules + +After cloning the main repo, load the submodule co2calculator. + +``` +git clone +cd WePledge +git submodule update --init +``` + +## Docker setup + +Run docker compose to create the docker images and start the containers + +``` +docker compose up +``` + +### Trouble shooting + +#### Error: standard_init_linux.go:219: exec user process caused: no such file or directory + +**Solution:** +1. Open *./backend/src/entrypoint.sh* and *./frontend/entrypoint.sh* in notepad++ +2. Go to edit -> EOL conversion -> change both from CRLF to LF. +3. Save the files. + + +#### Rebuild the images and containers after the backend code has changed + +Do the following to build the docker images, containers and volume from scratch to avoid errors: + +1. Delete all files except for the *__init__.py* in the folder *./WePledge/backend/src/emissions/migrations*. +2. Delete all backend containers (wepledge_pgadmin_1, wepledge_backend_1 and db) +3. Run `docker volume prune` to delete the volumes associated witch the deleted containers. +4. Run `docker compose up --build` to rebuild the images. + + +## Backend settings + +### User account verification via email + +During development the verification token can be either printed in the console of the backend container or be sent via email. This setting can be changed by editing `EMAIL_BACKEND` in [./backend/src/pledge4future/settings.py](https://github.com/pledge4future/WePledge/blob/dev-backend/backend/src/pledge4future/settings.py). + +`EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"`: Verification token printed in console +`EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'`: Verification through sending email + + +## Documentation + +### GraphQL API + +1.[Endpoint overview](./graphql/endpoint_overview.md) +2.[GraphDoc documentation of API](./graphdoc/index.html) +3.[User management requests](./graphql/user_management.md) +4.[Working group management requests](./graphql/working_group_management.md) +5.[Data mutations](./graphql/data_mutations.md) +6.[Data queries](./graphql/data_queries.md) +7.[Common errors](./graphql/errors.md) + +It might also be useful to look at the [GraphQL API tests](../src/emissions/tests/test_authentication.py) to see how the requests work. + + +### Django + +#### Visualization of database + +Run django locally and execute the command below to create a visulization of the [database structure](./img/database_structure.png). This requires pip install pydotplus and brew install graphviz in python environment +python manage.py graph_models emissions -a -o ../docs/img/database_structure.png --pydot + +``` +$ python manage.py graph_models emissions -a -o ../docs/database_structure.png --pydot +``` + +#### Generate API documentation + +Generate a [GraphDoc documentation](./graphdoc/index.html) of the current API while the API is running on docker. This requires installing [GraphDoc](https://2fd.github.io/graphdoc/). + +``` +cd ./backend/ +graphdoc -e http://localhost:8000/graphql/ -o ./docs/graphdoc --force +``` diff --git a/backend/docs/data/overall_emissions.md b/backend/docs/data/overall_emissions.md new file mode 100644 index 00000000..e79385cf --- /dev/null +++ b/backend/docs/data/overall_emissions.md @@ -0,0 +1,7 @@ +total emissions of the working group per month + +total emissions of the insitute per month + +average per capita emissions of the working group per month + +average per capita emissions of the insitute per month diff --git a/backend/docs/database_structure.png b/backend/docs/database_structure.png deleted file mode 100644 index d99c5b14..00000000 Binary files a/backend/docs/database_structure.png and /dev/null differ diff --git a/backend/docs/documentation.md b/backend/docs/documentation.md deleted file mode 100644 index 673df382..00000000 --- a/backend/docs/documentation.md +++ /dev/null @@ -1,97 +0,0 @@ -# Documentation - -## 1. Django - -##### Create visualization of database structure implemented as Django models - -``` -$ python manage.py graph_models emissions -a -o ../docs/database_structure.png -``` - -##### Set up database - -These command setup the database, create different user groups and populate the database with dummy data. - -``` -$ python manage.py makemigrations emissions -$ python manage.py migrate -$ python manage.py create_groups -$ python manage.py populate_data -``` - -###### Run server - -The super user only has to be created once after the database has been created. -``` -$ python manage.py createsuperuser -$ python manage.py runserver -``` - -Open `localhost:8000/admin` in the browser. Login as `super` user. - - -## 2. GraphQL - -After server is running open `localhost:8000/graphql` in the browser. - -### Resources -[Sanatan, M.: Building a GraphQL API with Django](https://stackabuse.com/building-a-graphql-api-with-django/) - -### Example queries - -#### 1. Query a user - -``` -query getUser { - user(id:3) { - id - username - } -} -``` - -#### 2. Edit user - -``` -mutation updateUser { - updateUser(id:3, input: { - firstName: "Bill" - }) { - ok - user { - firstName - } - } -} -``` - - -#### 3. Create new electricity entry - -``` -mutation createElectricity { - createElectricity (input: { - workinggroupid:1 - consumptionKwh: 3000 - fuelType: "solar" - timestamp: "2020-10-01" - }) { - ok - electricity { - id - timestamp - consumptionKwh - fuelType - co2e - } - } -} -``` - -## Error documentation - -### `Module not found` in backend container - -The backend container won't build correctly, because `Module not found django_extensions`. - -**Solution:** Delete all containerst and images. Then run `docker volume prune` to delete all data associated with them. \ No newline at end of file diff --git a/backend/docs/documentation_graphql.md b/backend/docs/documentation_graphql.md deleted file mode 100644 index 99117516..00000000 --- a/backend/docs/documentation_graphql.md +++ /dev/null @@ -1,123 +0,0 @@ -# GraphQL Documentation - - -After server is running open `localhost:8000/graphql` in the browser. - - -## Queries - -#### Query current user - -``` -query { - me { - username - } -} -``` - -#### Query user by id - -``` -query getUser { - user(id:3) { - id - username - } -} -``` -``` -query getUser { - user(username:"KarenAnderson") { - username - isRepresentative - } -} -``` - -#### Query all users - -``` -query { - users { - edges { - node { - username - email - } - } - } -} -``` - -### Mutations - -#### Edit user - -``` -mutation updateUser { - updateUser(id:3, input: { - firstName: "Bill" - }) { - ok - user { - firstName - } - } -} -``` - - -#### Create new electricity entry - -``` -mutation createElectricity { - createElectricity (input: { - username: "KarenAnderson" - consumptionKwh: 3000 - fuelType: "solar" - timestamp: "2020-10-01" - }) { - ok - electricity { - timestamp - consumptionKwh - fuelType - co2e - } - } -} -``` - -#### Create new heating entry - -``` -mutation createHeating{ - createHeating (input: { - username: "KarenAnderson" - consumptionKwh: 3000 - fuelType: "oil" - timestamp: "2022-10-01" - }) { - ok - heating { - timestamp - consumptionKwh - fuelType - co2e - } - } -} -``` - -## Error documentation - -### `Module not found` in backend container - -The backend container won't build correctly, because `Module not found django_extensions`. - -**Solution:** Delete all containerst and images. Then run `docker volume prune` to delete all data associated with them. - - -### Resources -[Sanatan, M.: Building a GraphQL API with Django](https://stackabuse.com/building-a-graphql-api-with-django/) diff --git a/backend/docs/graphdoc/archiveaccount.doc.html b/backend/docs/graphdoc/archiveaccount.doc.html new file mode 100644 index 00000000..47e49672 --- /dev/null +++ b/backend/docs/graphdoc/archiveaccount.doc.html @@ -0,0 +1,483 @@ + + + + + + + + + + ArchiveAccount + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

ArchiveAccount

+

Archive account and revoke refresh tokens.

+

User must be verified and confirm password.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/assets/code.css b/backend/docs/graphdoc/assets/code.css new file mode 100644 index 00000000..98f9e233 --- /dev/null +++ b/backend/docs/graphdoc/assets/code.css @@ -0,0 +1,143 @@ +.code { + background-color: #f6f6f6; + color: #4D4D4C; + border: 1px solid #4D4D4C; + font-size: 14px; + font-family: 'Ubuntu Mono'; + cursor: text; + list-style-type: decimal; + border-radius: 0.25rem; +} + +.code .gutter { + background: #f6f6f6; + color: #4D4D4C; +} + +.code .print-margin { + width: 1px; + background: #f6f6f6 +} + +.code li { + min-height: 1em; + background: #FFF; + padding: 1px 8px; +} +.code li:hover { + background: #EFEFEF; +} + +.code .tab { + padding-left: 2em; +} + +.code .cursor { + color: #AEAFAD +} + +.code .marker-layer .selection { + background: #D6D6D6 +} + +.code.multiselect .selection.start { + box-shadow: 0 0 3px 0px #FFFFFF; +} + +.code .marker-layer .step { + background: rgb(255, 255, 0) +} + +.code .marker-layer .bracket { + margin: -1px 0 0 -1px; + border: 1px solid #D1D1D1 +} + +.code .marker-layer .active-line { + background: #EFEFEF +} + +.code .gutter-active-line { + background-color: #dcdcdc +} + +.code .marker-layer .selected-word { + border: 1px solid #D6D6D6 +} + +.code .invisible { + color: #D1D1D1 +} + +.code .keyword, +.code .meta, +.code .storage, +.code .storage.type, +.code .support.type { + color: #8959A8 +} + +.code .keyword.operator { + color: #3E999F +} + +.code .constant.character, +.code .constant.language, +.code .constant.numeric, +.code .keyword.other.unit, +.code .support.constant { + color: #F5871F +} + +.code .constant.other { + color: #666969 +} + +.code .invalid { + color: #FFFFFF; + background-color: #C82829 +} + +.code .invalid.deprecated { + color: #FFFFFF; + background-color: #8959A8 +} + +.code .fold { + background-color: #4271AE; + border-color: #4D4D4C +} + +.code .entity.name.function, +.code .support.function, +.code .variable.parameter, +.code .variable { + color: #4271AE +} + +.code .support.class, +.code .support.type { + color: #C99E00 +} + +.code .heading, +.code .markup.heading, +.code .string { + color: #718C00 +} + +.code .entity.name.tag, +.code .entity.other.attribute-name, +.code .meta.tag, +.code .string.regexp, +.code .variable { + color: #C82829 +} + +.code .comment { + color: #8E908C +} + +.code .indent-guide { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bdu3f/BwAlfgctduB85QAAAABJRU5ErkJggg==) right repeat-y +} diff --git a/backend/docs/graphdoc/assets/require-by.css b/backend/docs/graphdoc/assets/require-by.css new file mode 100644 index 00000000..e027fb9e --- /dev/null +++ b/backend/docs/graphdoc/assets/require-by.css @@ -0,0 +1,43 @@ +div.require-by.anyone, +ul.require-by a { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; +} + +div.require-by.anyone { + background-color: #f0f8fc; + border: 1px solid #d8dde6; + color: grey; + padding: 2rem; + text-align: center; + margin: 1rem 0; + border-radius: 0.25rem; +} + +ul.require-by { + margin: 0; + padding: 0; +} + +ul.require-by a { + border-left: .25rem solid transparent; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + padding: .5rem 1.5rem; +} + +ul.require-by a:hover { + text-decoration: none; + background-color: #f0f8fc; + border-color: #d8dde6; + border-left-color: #005fb2; +} + +ul.require-by a em { + margin-left: 1rem; + font-size: .75rem; + color: grey; +} diff --git a/backend/docs/graphdoc/boolean.doc.html b/backend/docs/graphdoc/boolean.doc.html new file mode 100644 index 00000000..bedee3c6 --- /dev/null +++ b/backend/docs/graphdoc/boolean.doc.html @@ -0,0 +1,670 @@ + + + + + + + + + + Boolean + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

Boolean

+

The Boolean scalar type represents true or false.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar Boolean
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/businesstripaggregated.doc.html b/backend/docs/graphdoc/businesstripaggregated.doc.html new file mode 100644 index 00000000..bcac62df --- /dev/null +++ b/backend/docs/graphdoc/businesstripaggregated.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + BusinessTripAggregated + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

BusinessTripAggregated

+

GraphQL Business Trips aggregated by month or year

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type BusinessTripAggregated {
  • # Date
  • date: String
  • # Total CO2e emissions [tco2e]
  • co2e: Float
  • # CO2e emissions per capita [tco2e]
  • co2eCap: Float
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/businesstripinput.doc.html b/backend/docs/graphdoc/businesstripinput.doc.html new file mode 100644 index 00000000..cfd1b892 --- /dev/null +++ b/backend/docs/graphdoc/businesstripinput.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + BusinessTripInput + + + + +
+
+
+ +
+
+ +
+

INPUT_OBJECT

+

BusinessTripInput

+

GraphQL Input type for Business trips

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • input BusinessTripInput {
  • # Date
  • timestamp: Date!
  • # Transportation mode
  • transportationMode: String!
  • # Start address
  • start: String
  • # Destination address
  • destination: String
  • # Distance [meter]
  • distance: Float
  • # Size of the vehicle
  • size: String
  • # Fuel type of the vehicle
  • fuelType: String
  • # Occupancy
  • occupancy: Float
  • # Seating class in plane
  • seatingClass: Int
  • # Number of passengers
  • passengers: Int
  • # Roundtrip [True/False]
  • roundtrip: Boolean
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/businesstriptransportationmode.doc.html b/backend/docs/graphdoc/businesstriptransportationmode.doc.html new file mode 100644 index 00000000..41a9b6de --- /dev/null +++ b/backend/docs/graphdoc/businesstriptransportationmode.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + TransportationMode + + + + +
+
+
+ +
+
+ +
+

ENUM

+

TransportationMode

+

An enumeration.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • enum TransportationMode {
  • # Car
  • CAR
  • # Bus
  • BUS
  • # Train
  • TRAIN
  • # Plane
  • PLANE
  • }
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/businesstriptype.doc.html b/backend/docs/graphdoc/businesstriptype.doc.html new file mode 100644 index 00000000..63458e79 --- /dev/null +++ b/backend/docs/graphdoc/businesstriptype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + BusinessTripType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

BusinessTripType

+

GraphQL Business Trip Type

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/commutingaggregated.doc.html b/backend/docs/graphdoc/commutingaggregated.doc.html new file mode 100644 index 00000000..f2ed6309 --- /dev/null +++ b/backend/docs/graphdoc/commutingaggregated.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CommutingAggregated + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

CommutingAggregated

+

GraphQL Commuting aggregated by month or year

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type CommutingAggregated {
  • # Date
  • date: String
  • # Total CO2e emissions [tco2e]
  • co2e: Float
  • # CO2e emissions per capita [tco2e]
  • co2eCap: Float
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/commutinginput.doc.html b/backend/docs/graphdoc/commutinginput.doc.html new file mode 100644 index 00000000..45147a2b --- /dev/null +++ b/backend/docs/graphdoc/commutinginput.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CommutingInput + + + + +
+
+
+ +
+
+ +
+

INPUT_OBJECT

+

CommutingInput

+

GraphQL Input type for commuting

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • input CommutingInput {
  • # Start date
  • fromTimestamp: Date!
  • # End date
  • toTimestamp: Date!
  • # Transportation mode
  • transportationMode: String!
  • # Number of work weeks
  • workweeks: Int
  • # Distance [meter]
  • distance: Float
  • # Size of the vehicle
  • size: String
  • # Fuel type of the vehicle
  • fuelType: String
  • # Occupancy of the vehicle
  • occupancy: Float
  • # Number of passengers in the vehicle
  • passengers: Int
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/commutingtransportationmode.doc.html b/backend/docs/graphdoc/commutingtransportationmode.doc.html new file mode 100644 index 00000000..fd17fb15 --- /dev/null +++ b/backend/docs/graphdoc/commutingtransportationmode.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + TransportationMode + + + + +
+
+
+ +
+
+ +
+

ENUM

+

TransportationMode

+

An enumeration.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • enum TransportationMode {
  • # Car
  • CAR
  • # Bus
  • BUS
  • # Train
  • TRAIN
  • # Bicycle
  • BICYCLE
  • # E-bike
  • EBIKE
  • # Motorbike
  • MOTORBIKE
  • # Tram
  • TRAM
  • }
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/commutingtype.doc.html b/backend/docs/graphdoc/commutingtype.doc.html new file mode 100644 index 00000000..f24b6080 --- /dev/null +++ b/backend/docs/graphdoc/commutingtype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CommutingType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

CommutingType

+

GraphQL Commuting Type

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/createbusinesstrip.doc.html b/backend/docs/graphdoc/createbusinesstrip.doc.html new file mode 100644 index 00000000..bb915421 --- /dev/null +++ b/backend/docs/graphdoc/createbusinesstrip.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CreateBusinessTrip + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

CreateBusinessTrip

+

GraphQL mutation for business trips

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/createcommuting.doc.html b/backend/docs/graphdoc/createcommuting.doc.html new file mode 100644 index 00000000..00410ac8 --- /dev/null +++ b/backend/docs/graphdoc/createcommuting.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CreateCommuting + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

CreateCommuting

+

GraphQL mutation for commuting

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type CreateCommuting {
  • ok: Boolean
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/createelectricity.doc.html b/backend/docs/graphdoc/createelectricity.doc.html new file mode 100644 index 00000000..0bc90162 --- /dev/null +++ b/backend/docs/graphdoc/createelectricity.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CreateElectricity + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

CreateElectricity

+

GraphQL mutation for electricity

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/createheating.doc.html b/backend/docs/graphdoc/createheating.doc.html new file mode 100644 index 00000000..e442b379 --- /dev/null +++ b/backend/docs/graphdoc/createheating.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CreateHeating + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

CreateHeating

+

GraphQL mutation for heating

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/createworkinggroup.doc.html b/backend/docs/graphdoc/createworkinggroup.doc.html new file mode 100644 index 00000000..816bbf25 --- /dev/null +++ b/backend/docs/graphdoc/createworkinggroup.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CreateWorkingGroup + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

CreateWorkingGroup

+

Mutation to create a new working group

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/createworkinggroupinput.doc.html b/backend/docs/graphdoc/createworkinggroupinput.doc.html new file mode 100644 index 00000000..a5ab4b0b --- /dev/null +++ b/backend/docs/graphdoc/createworkinggroupinput.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + CreateWorkingGroupInput + + + + +
+
+
+ +
+
+ +
+

INPUT_OBJECT

+

CreateWorkingGroupInput

+

GraphQL Input type for creating a new working group

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • input CreateWorkingGroupInput {
  • # Name of the working group
  • name: String
  • # Name of institution the working group belongs to
  • institution: String!
  • # City of institution the working group belongs to
  • city: String!
  • # Country of institution the working group belongs to
  • country: String!
  • # Research field of working group
  • field: String!
  • # Research subfield of working group
  • subfield: String!
  • # Number of employees of working group
  • nEmployees: Int!
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/date.doc.html b/backend/docs/graphdoc/date.doc.html new file mode 100644 index 00000000..7557b486 --- /dev/null +++ b/backend/docs/graphdoc/date.doc.html @@ -0,0 +1,484 @@ + + + + + + + + + + Date + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

Date

+

The Date scalar type represents a Date +value as specified by +iso8601.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar Date
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/datetime.doc.html b/backend/docs/graphdoc/datetime.doc.html new file mode 100644 index 00000000..dc7062ad --- /dev/null +++ b/backend/docs/graphdoc/datetime.doc.html @@ -0,0 +1,484 @@ + + + + + + + + + + DateTime + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

DateTime

+

The DateTime scalar type represents a DateTime +value as specified by +iso8601.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar DateTime
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/deleteaccount.doc.html b/backend/docs/graphdoc/deleteaccount.doc.html new file mode 100644 index 00000000..3e1b3ff2 --- /dev/null +++ b/backend/docs/graphdoc/deleteaccount.doc.html @@ -0,0 +1,485 @@ + + + + + + + + + + DeleteAccount + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

DeleteAccount

+

Delete account permanently or make user.is_active=False.

+

The behavior is defined on settings. +Anyway user refresh tokens are revoked.

+

User must be verified and confirm password.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/directive.spec.html b/backend/docs/graphdoc/directive.spec.html new file mode 100644 index 00000000..5c47c1db --- /dev/null +++ b/backend/docs/graphdoc/directive.spec.html @@ -0,0 +1,483 @@ + + + + + + + + + + __Directive + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

__Directive

+

A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.

+

In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/directivelocation.spec.html b/backend/docs/graphdoc/directivelocation.spec.html new file mode 100644 index 00000000..6bcb6129 --- /dev/null +++ b/backend/docs/graphdoc/directivelocation.spec.html @@ -0,0 +1,486 @@ + + + + + + + + + + __DirectiveLocation + + + + +
+
+
+ +
+
+ +
+

ENUM

+

__DirectiveLocation

+

A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • enum __DirectiveLocation {
  • # Location adjacent to a query operation.
  • QUERY
  • # Location adjacent to a mutation operation.
  • MUTATION
  • # Location adjacent to a subscription operation.
  • SUBSCRIPTION
  • # Location adjacent to a field.
  • FIELD
  • # Location adjacent to a fragment definition.
  • FRAGMENT_DEFINITION
  • # Location adjacent to a fragment spread.
  • FRAGMENT_SPREAD
  • # Location adjacent to an inline fragment.
  • INLINE_FRAGMENT
  • # Location adjacent to a schema definition.
  • SCHEMA
  • # Location adjacent to a scalar definition.
  • SCALAR
  • # Location adjacent to an object definition.
  • OBJECT
  • # Location adjacent to a field definition.
  • FIELD_DEFINITION
  • # Location adjacent to an argument definition.
  • ARGUMENT_DEFINITION
  • # Location adjacent to an interface definition.
  • INTERFACE
  • # Location adjacent to a union definition.
  • UNION
  • # Location adjacent to an enum definition.
  • ENUM
  • # Location adjacent to an enum value definition.
  • ENUM_VALUE
  • # Location adjacent to an input object definition.
  • INPUT_OBJECT
  • # Location adjacent to an input object field definition.
  • INPUT_FIELD_DEFINITION
  • }
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/electricityaggregated.doc.html b/backend/docs/graphdoc/electricityaggregated.doc.html new file mode 100644 index 00000000..7ed21d06 --- /dev/null +++ b/backend/docs/graphdoc/electricityaggregated.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + ElectricityAggregated + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

ElectricityAggregated

+

GraphQL Electricity aggregated by month or year

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type ElectricityAggregated {
  • # Date
  • date: String
  • # Total CO2e emissions [tco2e]
  • co2e: Float
  • # CO2e emissions per capita [tco2e]
  • co2eCap: Float
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/electricityfueltype.doc.html b/backend/docs/graphdoc/electricityfueltype.doc.html new file mode 100644 index 00000000..17520b81 --- /dev/null +++ b/backend/docs/graphdoc/electricityfueltype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + ElectricityFuelType + + + + +
+
+
+ +
+
+ +
+

ENUM

+

ElectricityFuelType

+

An enumeration.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • enum ElectricityFuelType {
  • # German energy mix
  • GERMAN_ENERGY_MIX
  • # Solar
  • SOLAR
  • }
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/electricityinput.doc.html b/backend/docs/graphdoc/electricityinput.doc.html new file mode 100644 index 00000000..29bf23e9 --- /dev/null +++ b/backend/docs/graphdoc/electricityinput.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + ElectricityInput + + + + +
+
+
+ +
+
+ +
+

INPUT_OBJECT

+

ElectricityInput

+

GraphQL Input type for electricity

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • input ElectricityInput {
  • # Date
  • timestamp: Date!
  • # Consumption
  • consumption: Float
  • # Fuel type
  • fuelType: String!
  • # Number of Building if there are several ones
  • building: String!
  • # Share of the building beloning to the working group
  • groupShare: Float!
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/electricitytype.doc.html b/backend/docs/graphdoc/electricitytype.doc.html new file mode 100644 index 00000000..f3288653 --- /dev/null +++ b/backend/docs/graphdoc/electricitytype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + ElectricityType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

ElectricityType

+

GraphQL Electricity Type

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/enumvalue.spec.html b/backend/docs/graphdoc/enumvalue.spec.html new file mode 100644 index 00000000..5961fd64 --- /dev/null +++ b/backend/docs/graphdoc/enumvalue.spec.html @@ -0,0 +1,486 @@ + + + + + + + + + + __EnumValue + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

__EnumValue

+

One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/expectederrortype.doc.html b/backend/docs/graphdoc/expectederrortype.doc.html new file mode 100644 index 00000000..24259a42 --- /dev/null +++ b/backend/docs/graphdoc/expectederrortype.doc.html @@ -0,0 +1,684 @@ + + + + + + + + + + ExpectedErrorType + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

ExpectedErrorType

+
Errors messages and codes mapped to
+fields or non fields errors.
+Example:
+{
+    field_name: [
+        {
+            "message": "error message",
+            "code": "error_code"
+        }
+    ],
+    other_field: [
+        {
+            "message": "error message",
+            "code": "error_code"
+        }
+    ],
+    nonFieldErrors: [
+        {
+            "message": "error message",
+            "code": "error_code"
+        }
+    ]
+}
+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar ExpectedErrorType
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/field.spec.html b/backend/docs/graphdoc/field.spec.html new file mode 100644 index 00000000..f90ee9b9 --- /dev/null +++ b/backend/docs/graphdoc/field.spec.html @@ -0,0 +1,486 @@ + + + + + + + + + + __Field + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

__Field

+

Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/float.doc.html b/backend/docs/graphdoc/float.doc.html new file mode 100644 index 00000000..0adbcd83 --- /dev/null +++ b/backend/docs/graphdoc/float.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + Float + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

Float

+

The Float scalar type represents signed double-precision fractional values as specified by IEEE 754.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar Float
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.eot new file mode 100755 index 00000000..b3916059 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.svg new file mode 100755 index 00000000..16a3ae64 --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.woff new file mode 100755 index 00000000..6ec23a86 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.woff2 new file mode 100755 index 00000000..8425952a Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Bold.woff2 differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.eot new file mode 100755 index 00000000..47da582e Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.svg new file mode 100755 index 00000000..2f31ec61 --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.woff new file mode 100755 index 00000000..bd44622b Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.woff2 new file mode 100755 index 00000000..ce640f22 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-BoldItalic.woff2 differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.eot new file mode 100755 index 00000000..7cac6cf3 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.svg new file mode 100755 index 00000000..a893ccf6 --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.woff new file mode 100755 index 00000000..aa6a6b38 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.woff2 new file mode 100755 index 00000000..f729b677 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Italic.woff2 differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.eot new file mode 100755 index 00000000..52a7b684 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.svg new file mode 100755 index 00000000..cde501de --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.woff new file mode 100755 index 00000000..dc25e65c Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.woff2 new file mode 100755 index 00000000..f467a3b0 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Light.woff2 differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.eot new file mode 100755 index 00000000..2c0ceeb6 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.svg new file mode 100755 index 00000000..e62fbc0c --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.woff new file mode 100755 index 00000000..15cbe9f1 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.woff2 new file mode 100755 index 00000000..6aef5418 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-LightItalic.woff2 differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.eot new file mode 100755 index 00000000..33e29eb4 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.svg new file mode 100755 index 00000000..1312dc3d --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.woff new file mode 100755 index 00000000..b858d090 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.woff2 new file mode 100755 index 00000000..37c76713 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Regular.woff2 differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.eot new file mode 100755 index 00000000..6fcba44f Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.svg new file mode 100755 index 00000000..76fcd421 --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.woff new file mode 100755 index 00000000..3a2285ee Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.woff2 new file mode 100755 index 00000000..8b2be1c5 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-Thin.woff2 differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.eot b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.eot new file mode 100755 index 00000000..6eaefd6d Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.eot differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.svg b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.svg new file mode 100755 index 00000000..828ae6b5 --- /dev/null +++ b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.svg @@ -0,0 +1,430 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.woff b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.woff new file mode 100755 index 00000000..cd498ec6 Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.woff differ diff --git a/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.woff2 b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.woff2 new file mode 100755 index 00000000..a51b2eaa Binary files /dev/null and b/backend/docs/graphdoc/fonts/webfonts/SalesforceSans-ThinItalic.woff2 differ diff --git a/backend/docs/graphdoc/genericscalar.doc.html b/backend/docs/graphdoc/genericscalar.doc.html new file mode 100644 index 00000000..de622134 --- /dev/null +++ b/backend/docs/graphdoc/genericscalar.doc.html @@ -0,0 +1,484 @@ + + + + + + + + + + GenericScalar + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

GenericScalar

+

The GenericScalar scalar type represents a generic +GraphQL scalar value that could be: +String, Boolean, Int, Float, List or Object.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar GenericScalar
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/heatingaggregated.doc.html b/backend/docs/graphdoc/heatingaggregated.doc.html new file mode 100644 index 00000000..80858c1a --- /dev/null +++ b/backend/docs/graphdoc/heatingaggregated.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + HeatingAggregated + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

HeatingAggregated

+

GraphQL Heating aggregated by month or year

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type HeatingAggregated {
  • # Date
  • date: String
  • # Total CO2e emissions [tco2e]
  • co2e: Float
  • # CO2e emissions per capita [tco2e]
  • co2eCap: Float
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/heatingfueltype.doc.html b/backend/docs/graphdoc/heatingfueltype.doc.html new file mode 100644 index 00000000..aef79adf --- /dev/null +++ b/backend/docs/graphdoc/heatingfueltype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + HeatingFuelType + + + + +
+
+
+ +
+
+ +
+

ENUM

+

HeatingFuelType

+

An enumeration.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • enum HeatingFuelType {
  • # Heat pump air
  • HEAT_PUMP_AIR
  • # Heat pump ground
  • HEAT_PUMP_GROUND
  • # Heat pump water
  • HEAT_PUMP_WATER
  • # Liquid gas
  • LIQUID_GAS
  • # Oil
  • OIL
  • # Pellets
  • PELLETS
  • # Solar
  • SOLAR
  • # Woodchips
  • WOODCHIPS
  • # Electricity
  • ELECTRICITY
  • # Gas
  • GAS
  • # Coal
  • COAL
  • # District heating
  • DISTRICT_HEATING
  • }
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/heatinginput.doc.html b/backend/docs/graphdoc/heatinginput.doc.html new file mode 100644 index 00000000..ead8e3ab --- /dev/null +++ b/backend/docs/graphdoc/heatinginput.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + HeatingInput + + + + +
+
+
+ +
+
+ +
+

INPUT_OBJECT

+

HeatingInput

+

GraphQL Input type for heating

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • input HeatingInput {
  • # Date
  • timestamp: Date!
  • # Consumption
  • consumption: Float!
  • # Unit of fuel type
  • unit: String!
  • # Fuel type
  • fuelType: String!
  • # Number of Building if there are several ones
  • building: String!
  • # Share of the building beloning to the working group
  • groupShare: Float!
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/heatingtype.doc.html b/backend/docs/graphdoc/heatingtype.doc.html new file mode 100644 index 00000000..7c6c2636 --- /dev/null +++ b/backend/docs/graphdoc/heatingtype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + HeatingType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

HeatingType

+

GraphQL Heating Type

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/heatingunit.doc.html b/backend/docs/graphdoc/heatingunit.doc.html new file mode 100644 index 00000000..469d371b --- /dev/null +++ b/backend/docs/graphdoc/heatingunit.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + HeatingUnit + + + + +
+
+
+ +
+
+ +
+

ENUM

+

HeatingUnit

+

An enumeration.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • enum HeatingUnit {
  • # kwh
  • KWH
  • # kg
  • KG
  • # l
  • L
  • # m^3
  • M3
  • }
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/id.doc.html b/backend/docs/graphdoc/id.doc.html new file mode 100644 index 00000000..f1ac3ab3 --- /dev/null +++ b/backend/docs/graphdoc/id.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + ID + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

ID

+

The ID scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as "4") or integer (such as 4) input value will be accepted as an ID.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar ID
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/include.doc.html b/backend/docs/graphdoc/include.doc.html new file mode 100644 index 00000000..e6446f18 --- /dev/null +++ b/backend/docs/graphdoc/include.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + include + + + + +
+
+
+ +
+
+ +
+

+

include

+

Directs the executor to include this field or fragment only when the if argument is true.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
+
+
+
+
+

+ + link + + Require by +

+
This element is not required by anyone
+
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/index.html b/backend/docs/graphdoc/index.html new file mode 100644 index 00000000..7955993a --- /dev/null +++ b/backend/docs/graphdoc/index.html @@ -0,0 +1,470 @@ + + + + + + + + + + Graphql schema documentation + + + + +
+
+
+ +
+
+ +
+ +

Graphql schema documentation

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • schema {
  • # GraphQL Queries
  • query: Query
  • # GraphQL Mutations
  • mutation: Mutation
  • }
+
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/inputvalue.spec.html b/backend/docs/graphdoc/inputvalue.spec.html new file mode 100644 index 00000000..7105bc66 --- /dev/null +++ b/backend/docs/graphdoc/inputvalue.spec.html @@ -0,0 +1,490 @@ + + + + + + + + + + __InputValue + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

__InputValue

+

Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/institutiontype.doc.html b/backend/docs/graphdoc/institutiontype.doc.html new file mode 100644 index 00000000..8fbf7b7e --- /dev/null +++ b/backend/docs/graphdoc/institutiontype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + InstitutionType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

InstitutionType

+

GraphQL Institution

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/int.doc.html b/backend/docs/graphdoc/int.doc.html new file mode 100644 index 00000000..f18195d1 --- /dev/null +++ b/backend/docs/graphdoc/int.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + Int + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

Int

+

The Int scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31 - 1) and 2^31 - 1 since represented in JSON as double-precision floating point numbers specifiedby IEEE 754.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar Int
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/mutation.doc.html b/backend/docs/graphdoc/mutation.doc.html new file mode 100644 index 00000000..658b7436 --- /dev/null +++ b/backend/docs/graphdoc/mutation.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + Mutation + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

Mutation

+

GraphQL Mutations

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type Mutation {
  • # Register user with fields defined in the settings.
  • #
  • # If the email field of the user model is part of the
  • # registration fields (default), check if there is
  • # no user with that email or as a secondary email.
  • #
  • # If it exists, it does not register the user,
  • # even if the email field is not defined as unique
  • # (default of the default django user model).
  • #
  • # When creating the user, it also creates a `UserStatus`
  • # related to that user, making it possible to track
  • # if the user is archived, verified and has a secondary
  • # email.
  • #
  • # Send account verification email.
  • #
  • # If allowed to not verified users login, return token.
  • #
  • # Arguments
  • # email: [Not documented]
  • # username: [Not documented]
  • # password1: [Not documented]
  • # password2: [Not documented]
  • register(
  • email: String!,
  • username: String!,
  • password1: String!,
  • password2: String!
  • ): Register
  • # Verify user account.
  • #
  • # Receive the token that was sent by email.
  • # If the token is valid, make the user verified
  • # by making the `user.status.verified` field true.
  • #
  • # Arguments
  • # token: [Not documented]
  • verifyAccount(token: String!): VerifyAccount
  • # Sends activation email.
  • #
  • # It is called resend because theoretically
  • # the first activation email was sent when
  • # the user registered.
  • #
  • # If there is no user with the requested email,
  • # a successful response is returned.
  • #
  • # Arguments
  • # email: [Not documented]
  • resendActivationEmail(email: String!): ResendActivationEmail
  • # Send password reset email.
  • #
  • # For non verified users, send an activation
  • # email instead.
  • #
  • # Accepts both primary and secondary email.
  • #
  • # If there is no user with the requested email,
  • # a successful response is returned.
  • #
  • # Arguments
  • # email: [Not documented]
  • sendPasswordResetEmail(email: String!): SendPasswordResetEmail
  • # Change user password without old password.
  • #
  • # Receive the token that was sent by email.
  • #
  • # If token and new passwords are valid, update
  • # user password and in case of using refresh
  • # tokens, revoke all of them.
  • #
  • # Also, if user has not been verified yet, verify it.
  • #
  • # Arguments
  • # token: [Not documented]
  • # newPassword1: [Not documented]
  • # newPassword2: [Not documented]
  • passwordReset(
  • token: String!,
  • newPassword1: String!,
  • newPassword2: String!
  • ): PasswordReset
  • # Set user password - for passwordless registration
  • #
  • # Receive the token that was sent by email.
  • #
  • # If token and new passwords are valid, set
  • # user password and in case of using refresh
  • # tokens, revoke all of them.
  • #
  • # Also, if user has not been verified yet, verify it.
  • #
  • # Arguments
  • # token: [Not documented]
  • # newPassword1: [Not documented]
  • # newPassword2: [Not documented]
  • passwordSet(token: String!, newPassword1: String!, newPassword2: String!): PasswordSet
  • # Archive account and revoke refresh tokens.
  • #
  • # User must be verified and confirm password.
  • #
  • # Arguments
  • # password: [Not documented]
  • archiveAccount(password: String!): ArchiveAccount
  • # Delete account permanently or make `user.is_active=False`.
  • #
  • # The behavior is defined on settings.
  • # Anyway user refresh tokens are revoked.
  • #
  • # User must be verified and confirm password.
  • #
  • # Arguments
  • # password: [Not documented]
  • deleteAccount(password: String!): DeleteAccount
  • # Change account password when user knows the old password.
  • #
  • # A new token and refresh token are sent. User must be verified.
  • #
  • # Arguments
  • # oldPassword: [Not documented]
  • # newPassword1: [Not documented]
  • # newPassword2: [Not documented]
  • passwordChange(
  • oldPassword: String!,
  • newPassword1: String!,
  • newPassword2: String!
  • ): PasswordChange
  • # Update user model fields, defined on settings.
  • #
  • # User must be verified.
  • #
  • # Arguments
  • # firstName: [Not documented]
  • # lastName: [Not documented]
  • # isRepresentative: [Not documented]
  • updateAccount(
  • firstName: String,
  • lastName: String,
  • isRepresentative: String
  • ): UpdateAccount
  • # Send activation to secondary email.
  • #
  • # User must be verified and confirm password.
  • #
  • # Arguments
  • # email: [Not documented]
  • # password: [Not documented]
  • sendSecondaryEmailActivation(
  • email: String!,
  • password: String!
  • ): SendSecondaryEmailActivation
  • # Verify user secondary email.
  • #
  • # Receive the token that was sent by email.
  • # User is already verified when using this mutation.
  • #
  • # If the token is valid, add the secondary email
  • # to `user.status.secondary_email` field.
  • #
  • # Note that until the secondary email is verified,
  • # it has not been saved anywhere beyond the token,
  • # so it can still be used to create a new account.
  • # After being verified, it will no longer be available.
  • #
  • # Arguments
  • # token: [Not documented]
  • verifySecondaryEmail(token: String!): VerifySecondaryEmail
  • # Swap between primary and secondary emails.
  • #
  • # Require password confirmation.
  • #
  • # Arguments
  • # password: [Not documented]
  • swapEmails(password: String!): SwapEmails
  • # Remove user secondary email.
  • #
  • # Require password confirmation.
  • #
  • # Arguments
  • # password: [Not documented]
  • removeSecondaryEmail(password: String!): RemoveSecondaryEmail
  • # Obtain JSON web token for given user.
  • #
  • # Allow to perform login with different fields,
  • # and secondary email if set. The fields are
  • # defined on settings.
  • #
  • # Not verified users can login by default. This
  • # can be changes on settings.
  • #
  • # If user is archived, make it unarchive and
  • # return `unarchiving=True` on output.
  • #
  • # Arguments
  • # password: [Not documented]
  • # email: [Not documented]
  • # username: [Not documented]
  • tokenAuth(password: String!, email: String, username: String): ObtainJSONWebToken
  • # Same as `grapgql_jwt` implementation, with standard output.
  • #
  • # Arguments
  • # token: [Not documented]
  • verifyToken(token: String!): VerifyToken
  • # Same as `grapgql_jwt` implementation, with standard output.
  • #
  • # Arguments
  • # refreshToken: [Not documented]
  • refreshToken(refreshToken: String!): RefreshToken
  • # Same as `grapgql_jwt` implementation, with standard output.
  • #
  • # Arguments
  • # refreshToken: [Not documented]
  • revokeToken(refreshToken: String!): RevokeToken
  • # GraphQL mutation for business trips
  • #
  • # Arguments
  • # input: [Not documented]
  • createBusinesstrip(input: BusinessTripInput!): CreateBusinessTrip
  • # GraphQL mutation for electricity
  • #
  • # Arguments
  • # input: [Not documented]
  • createElectricity(input: ElectricityInput!): CreateElectricity
  • # GraphQL mutation for heating
  • #
  • # Arguments
  • # input: [Not documented]
  • createHeating(input: HeatingInput!): CreateHeating
  • # GraphQL mutation for commuting
  • #
  • # Arguments
  • # input: [Not documented]
  • createCommuting(input: CommutingInput!): CreateCommuting
  • # GraphQL mutation to set working group of user
  • #
  • # Arguments
  • # input: [Not documented]
  • setWorkingGroup(input: WorkingGroupInput): SetWorkingGroup
  • # Mutation to create a new working group
  • #
  • # Arguments
  • # input: [Not documented]
  • createWorkingGroup(input: CreateWorkingGroupInput): CreateWorkingGroup
  • }
+
+
+
+
+

+ + link + + Require by +

+
This element is not required by anyone
+
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/node.doc.html b/backend/docs/graphdoc/node.doc.html new file mode 100644 index 00000000..8bf5f8fd --- /dev/null +++ b/backend/docs/graphdoc/node.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + Node + + + + +
+
+
+ +
+
+ +
+

INTERFACE

+

Node

+

An object with an ID

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • interface Node {
  • # The ID of the object.
  • id: ID!
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/obtainjsonwebtoken.doc.html b/backend/docs/graphdoc/obtainjsonwebtoken.doc.html new file mode 100644 index 00000000..07149815 --- /dev/null +++ b/backend/docs/graphdoc/obtainjsonwebtoken.doc.html @@ -0,0 +1,489 @@ + + + + + + + + + + ObtainJSONWebToken + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

ObtainJSONWebToken

+

Obtain JSON web token for given user.

+

Allow to perform login with different fields, +and secondary email if set. The fields are +defined on settings.

+

Not verified users can login by default. This +can be changes on settings.

+

If user is archived, make it unarchive and +return unarchiving=True on output.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/pageinfo.doc.html b/backend/docs/graphdoc/pageinfo.doc.html new file mode 100644 index 00000000..2bf2aeb5 --- /dev/null +++ b/backend/docs/graphdoc/pageinfo.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + PageInfo + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

PageInfo

+

The Relay compliant PageInfo type, containing data necessary to paginate this connection.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type PageInfo {
  • # When paginating forwards, are there more items?
  • hasNextPage: Boolean!
  • # When paginating backwards, are there more items?
  • hasPreviousPage: Boolean!
  • # When paginating backwards, the cursor to continue.
  • startCursor: String
  • # When paginating forwards, the cursor to continue.
  • endCursor: String
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/passwordchange.doc.html b/backend/docs/graphdoc/passwordchange.doc.html new file mode 100644 index 00000000..5298753f --- /dev/null +++ b/backend/docs/graphdoc/passwordchange.doc.html @@ -0,0 +1,483 @@ + + + + + + + + + + PasswordChange + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

PasswordChange

+

Change account password when user knows the old password.

+

A new token and refresh token are sent. User must be verified.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/passwordreset.doc.html b/backend/docs/graphdoc/passwordreset.doc.html new file mode 100644 index 00000000..42dd13be --- /dev/null +++ b/backend/docs/graphdoc/passwordreset.doc.html @@ -0,0 +1,487 @@ + + + + + + + + + + PasswordReset + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

PasswordReset

+

Change user password without old password.

+

Receive the token that was sent by email.

+

If token and new passwords are valid, update +user password and in case of using refresh +tokens, revoke all of them.

+

Also, if user has not been verified yet, verify it.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/passwordset.doc.html b/backend/docs/graphdoc/passwordset.doc.html new file mode 100644 index 00000000..869b3e74 --- /dev/null +++ b/backend/docs/graphdoc/passwordset.doc.html @@ -0,0 +1,487 @@ + + + + + + + + + + PasswordSet + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

PasswordSet

+

Set user password - for passwordless registration

+

Receive the token that was sent by email.

+

If token and new passwords are valid, set +user password and in case of using refresh +tokens, revoke all of them.

+

Also, if user has not been verified yet, verify it.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/query.doc.html b/backend/docs/graphdoc/query.doc.html new file mode 100644 index 00000000..cc5a0de7 --- /dev/null +++ b/backend/docs/graphdoc/query.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + Query + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

Query

+

GraphQL Queries

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type Query {
  • me: UserNode
  • # The ID of the object
  • #
  • # Arguments
  • # id: [Not documented]
  • user(id: ID!): UserNode
  • # Arguments
  • # offset: [Not documented]
  • # before: [Not documented]
  • # after: [Not documented]
  • # first: [Not documented]
  • # last: [Not documented]
  • # email: [Not documented]
  • # username: [Not documented]
  • # username_Icontains: [Not documented]
  • # username_Istartswith: [Not documented]
  • # isActive: [Not documented]
  • # status_Archived: [Not documented]
  • # status_Verified: [Not documented]
  • # status_SecondaryEmail: [Not documented]
  • users(
  • offset: Int,
  • before: String,
  • after: String,
  • first: Int,
  • last: Int,
  • email: String,
  • username: String,
  • username_Icontains: String,
  • username_Istartswith: String,
  • isActive: Boolean,
  • status_Archived: Boolean,
  • status_Verified: Boolean,
  • status_SecondaryEmail: String
  • ): UserNodeConnection
  • businesstrips: [BusinessTripType]
  • electricities: [ElectricityType]
  • heatings: [HeatingType]
  • commutings: [CommutingType]
  • workinggroups: [WorkingGroupType]
  • researchfields: [ResearchFieldType]
  • institutions: [InstitutionType]
  • # Arguments
  • # level: Aggregation level: group or institution. Default: group
  • # timeInterval: Time interval for aggregation (month or year)
  • heatingAggregated(level: String, timeInterval: String): [HeatingAggregated]
  • # Arguments
  • # level: Aggregation level: group or institution. Default: group
  • # timeInterval: Time interval for aggregation (month or year)
  • electricityAggregated(level: String, timeInterval: String): [ElectricityAggregated]
  • # Arguments
  • # level: Aggregation level: personal, group or institution.
  • # Default: group
  • # timeInterval: Time interval for aggregation (month or year)
  • businesstripAggregated(level: String, timeInterval: String): [BusinessTripAggregated]
  • # Arguments
  • # level: Aggregation level: personal, group or institution.
  • # Default: group
  • # timeInterval: Time interval for aggregation (month or year)
  • commutingAggregated(level: String, timeInterval: String): [CommutingAggregated]
  • # Arguments
  • # start: Start date for calculation of total emissions
  • # end: End date for calculation of total emissions
  • # level: Aggregate by 'group' or 'institution'. Default: 'group')
  • totalEmission(start: Date, end: Date, level: String): [TotalEmission]
  • }
+
+
+
+
+

+ + link + + Require by +

+
This element is not required by anyone
+
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/refreshtoken.doc.html b/backend/docs/graphdoc/refreshtoken.doc.html new file mode 100644 index 00000000..1184ccd3 --- /dev/null +++ b/backend/docs/graphdoc/refreshtoken.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + RefreshToken + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

RefreshToken

+

Same as grapgql_jwt implementation, with standard output.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/register.doc.html b/backend/docs/graphdoc/register.doc.html new file mode 100644 index 00000000..2545c511 --- /dev/null +++ b/backend/docs/graphdoc/register.doc.html @@ -0,0 +1,494 @@ + + + + + + + + + + Register + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

Register

+

Register user with fields defined in the settings.

+

If the email field of the user model is part of the +registration fields (default), check if there is +no user with that email or as a secondary email.

+

If it exists, it does not register the user, +even if the email field is not defined as unique +(default of the default django user model).

+

When creating the user, it also creates a UserStatus +related to that user, making it possible to track +if the user is archived, verified and has a secondary +email.

+

Send account verification email.

+

If allowed to not verified users login, return token.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/removesecondaryemail.doc.html b/backend/docs/graphdoc/removesecondaryemail.doc.html new file mode 100644 index 00000000..31c90940 --- /dev/null +++ b/backend/docs/graphdoc/removesecondaryemail.doc.html @@ -0,0 +1,483 @@ + + + + + + + + + + RemoveSecondaryEmail + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

RemoveSecondaryEmail

+

Remove user secondary email.

+

Require password confirmation.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/researchfieldtype.doc.html b/backend/docs/graphdoc/researchfieldtype.doc.html new file mode 100644 index 00000000..2f3e076b --- /dev/null +++ b/backend/docs/graphdoc/researchfieldtype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + ResearchFieldType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

ResearchFieldType

+

GraphQL Research Field

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/resendactivationemail.doc.html b/backend/docs/graphdoc/resendactivationemail.doc.html new file mode 100644 index 00000000..addecaa3 --- /dev/null +++ b/backend/docs/graphdoc/resendactivationemail.doc.html @@ -0,0 +1,487 @@ + + + + + + + + + + ResendActivationEmail + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

ResendActivationEmail

+

Sends activation email.

+

It is called resend because theoretically +the first activation email was sent when +the user registered.

+

If there is no user with the requested email, +a successful response is returned.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/revoketoken.doc.html b/backend/docs/graphdoc/revoketoken.doc.html new file mode 100644 index 00000000..bb1e7b55 --- /dev/null +++ b/backend/docs/graphdoc/revoketoken.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + RevokeToken + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

RevokeToken

+

Same as grapgql_jwt implementation, with standard output.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/schema.spec.html b/backend/docs/graphdoc/schema.spec.html new file mode 100644 index 00000000..26e6a53b --- /dev/null +++ b/backend/docs/graphdoc/schema.spec.html @@ -0,0 +1,482 @@ + + + + + + + + + + __Schema + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

__Schema

+

A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation and subscription operations.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type __Schema {
  • # A list of all types supported by this server.
  • types: [__Type!]!
  • # The type that query operations will be rooted at.
  • queryType: __Type!
  • # If this server supports mutation, the type that mutation operations will be
  • # rooted at.
  • mutationType: __Type
  • # If this server support subscription, the type that subscription operations will
  • # be rooted at.
  • subscriptionType: __Type
  • # A list of all directives supported by this server.
  • directives: [__Directive!]!
  • }
+
+
+
+
+

+ + link + + Require by +

+
This element is not required by anyone
+
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/scripts/filter-types.js b/backend/docs/graphdoc/scripts/filter-types.js new file mode 100644 index 00000000..f3eb7240 --- /dev/null +++ b/backend/docs/graphdoc/scripts/filter-types.js @@ -0,0 +1,102 @@ +(function () { + var HIDE_CLASS = 'slds-hide'; + var ITEM_CLASS = 'slds-item'; + + /** + * @class Item + * @param {HTMLLIElement} li + */ + function Item(li) { + this.li = li; + this.type = li.title; + this.typeLowerCase = li.title.toLowerCase(); + } + + /** + * @return boolean + */ + Item.prototype.contains = function (searchText) { + return this.typeLowerCase.indexOf(searchText) >= 0; + } + + /** + * @return boolean + */ + Item.prototype.isHide = function () { + this.li.classList.contains(HIDE_CLASS); + } + + /** + * @return void + */ + Item.prototype.hide = function () { + if (!this.isHide()) + this.li.classList.add(HIDE_CLASS); + } + + /** + * @return void + */ + Item.prototype.show = function () { + this.li.classList.remove(HIDE_CLASS); + } + + /** + * @class ItemList + * @param {Item[]} items + */ + function ItemList(items) { + this.items = items; + } + + /** + * @function ItemsList.fromSelector + * @param {string} selector + * @return ItemList + */ + ItemList.fromSelector = function (selector) { + + var lis = document.querySelectorAll(selector); + var items = Array.prototype.map.call(lis, function (li) { + return new Item(li); + }) + + return new ItemList(items); + } + + /** + * @return void + */ + ItemList.prototype.showIfmatch = function (match) { + + match = match.toLowerCase(match); + + this + .items + .forEach(function (item) { + item.contains(match) ? + item.show(): + item.hide(); + }) + } + + /** + * @var {ItemList} items + * @var {HTMLInputElement} input + */ + var items = ItemList.fromSelector('nav .slds-navigation-list--vertical li'); + var input = document.getElementById('type-search'); + var lastMatch = ''; + + function onChange() { + if (input.value === lastMatch) + return; + + lastMatch = input.value; + items.showIfmatch(lastMatch); + } + + input.addEventListener('change', onChange); + input.addEventListener('keyup', onChange); + input.addEventListener('mouseup', onChange); +})() diff --git a/backend/docs/graphdoc/scripts/focus-active.js b/backend/docs/graphdoc/scripts/focus-active.js new file mode 100644 index 00000000..8d84495e --- /dev/null +++ b/backend/docs/graphdoc/scripts/focus-active.js @@ -0,0 +1,8 @@ +(function () { + var navScroll = document.getElementById('navication-scroll'); + var header = document.querySelector('nav header'); + var active = document.querySelector('.slds-is-active a'); + + if(active) + navScroll.scrollTop = active.offsetTop - header.offsetHeight - Math.ceil(active.offsetHeight / 2) +})() diff --git a/backend/docs/graphdoc/scripts/toggle-navigation.js b/backend/docs/graphdoc/scripts/toggle-navigation.js new file mode 100644 index 00000000..d43a2818 --- /dev/null +++ b/backend/docs/graphdoc/scripts/toggle-navigation.js @@ -0,0 +1,23 @@ +(function () { + + var ACTIVE_CLASS = 'is-active'; + var navigation = document.querySelector('nav'); + var toggles = document.querySelectorAll('.js-toggle-navigation'); + + function toggleNavigation() { + navigation.classList.contains(ACTIVE_CLASS) ? + navigation.classList.remove(ACTIVE_CLASS) : + navigation.classList.add(ACTIVE_CLASS); + } + + Array.prototype.forEach.call( + toggles, + /** + * @param {HTMLElement} toggle + */ + function (toggle) { + toggle.addEventListener('click', toggleNavigation); + } + ) + +})() diff --git a/backend/docs/graphdoc/sendpasswordresetemail.doc.html b/backend/docs/graphdoc/sendpasswordresetemail.doc.html new file mode 100644 index 00000000..d1546ecd --- /dev/null +++ b/backend/docs/graphdoc/sendpasswordresetemail.doc.html @@ -0,0 +1,487 @@ + + + + + + + + + + SendPasswordResetEmail + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

SendPasswordResetEmail

+

Send password reset email.

+

For non verified users, send an activation +email instead.

+

Accepts both primary and secondary email.

+

If there is no user with the requested email, +a successful response is returned.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/sendsecondaryemailactivation.doc.html b/backend/docs/graphdoc/sendsecondaryemailactivation.doc.html new file mode 100644 index 00000000..a1962922 --- /dev/null +++ b/backend/docs/graphdoc/sendsecondaryemailactivation.doc.html @@ -0,0 +1,483 @@ + + + + + + + + + + SendSecondaryEmailActivation + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

SendSecondaryEmailActivation

+

Send activation to secondary email.

+

User must be verified and confirm password.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/setworkinggroup.doc.html b/backend/docs/graphdoc/setworkinggroup.doc.html new file mode 100644 index 00000000..5e0f6fe3 --- /dev/null +++ b/backend/docs/graphdoc/setworkinggroup.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + SetWorkingGroup + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

SetWorkingGroup

+

GraphQL mutation to set working group of user

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/skip.doc.html b/backend/docs/graphdoc/skip.doc.html new file mode 100644 index 00000000..8fe5dcfc --- /dev/null +++ b/backend/docs/graphdoc/skip.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + skip + + + + +
+
+
+ +
+
+ +
+

+

skip

+

Directs the executor to skip this field or fragment when the if argument is true.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
+
+
+
+
+

+ + link + + Require by +

+
This element is not required by anyone
+
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/string.doc.html b/backend/docs/graphdoc/string.doc.html new file mode 100644 index 00000000..08ea05ce --- /dev/null +++ b/backend/docs/graphdoc/string.doc.html @@ -0,0 +1,514 @@ + + + + + + + + + + String + + + + +
+
+
+ +
+
+ +
+

SCALAR

+

String

+

The String scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • scalar String
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/styles/_custom.scss b/backend/docs/graphdoc/styles/_custom.scss new file mode 100644 index 00000000..66a2f163 --- /dev/null +++ b/backend/docs/graphdoc/styles/_custom.scss @@ -0,0 +1,147 @@ +html { + height: 100%; +} + +body { + min-height: 100%; + display: block; + padding: 0; + margin: 0; +} + +nav { + left: $spacing-none; + top: $spacing-none; + width: 30%; + max-width: $size-medium; + padding: $spacing-none; + background: white; + z-index: $z-index-sticky; + position: fixed; + height: 100%; + overflow: auto; + box-sizing: border-box; + box-shadow: $shadow-drop-down; + transform: translateX(0); + transition: #{$duration-promptly} transform ease; + @media (max-width: #{$mq-medium}) { + transform: translateX(-100%); + width: $size-medium; + &.is-active { + transform: translateX(0); + } + } +} + +nav sup { + font-style: italic; + opacity: .7; +} + +header a { + text-decoration: none !important; +} + +main section, +main footer { + width: 100%; + padding: 0 0 0 30%; + position: relative; + @media (max-width: #{$mq-medium}) { + padding-left: 0; + } + @media (min-width: 70rem) { + padding-left: 21rem; + } +} + +main .title, +main footer { + padding-bottom: $spacing-xx-large; +} + +main .title { + background: $color-brand-darker; +} + +main .title .slds-text-title--caps, +main .title .slds-button { + color: inherit +} + +main .container { + box-sizing: border-box; + padding: $spacing-small $spacing-large; + max-width: $size-xx-large; + @media (max-width: #{$mq-medium}) { + overflow: auto; + } +} + +main code.highlight { + display: block; + background: #4D4D4C; + position: relative; + box-shadow: $elevation-shadow-4; + padding: $spacing-xxx-small; + padding-top: $spacing-large; + border-radius: $border-radius-medium; + min-width: 50rem; +} + +main code.highlight::after, +main code.highlight::before { + content: ""; + display: block; + height: $spacing-small; + width: $spacing-small; + position: absolute; + top: $spacing-x-small; //border: 1px solid darken($color: #4D4D4C, $amount: 10%); + border-radius: 100%; + cursor: pointer; +} + +main code.highlight::before { + background: $color-background-destructive; + left: $spacing-x-small; +} + +main code.highlight::after { + background: lighten($color: #4D4D4C, $amount: 10%); + left: $spacing-x-small + $square-icon-utility-small + $spacing-xx-small; +} + +.less-than-medium { + @media (min-width: #{$mq-medium}) { + display: none; + } +} + +.less-than-small { + @media (min-width: #{$mq-small}) { + display: none; + } +} + +.graphdoc-section__title { + border-bottom: #{$border-width-thin} solid #{$color-border}; + margin-bottom: $spacing-large; + position: relative; +} + +.graphdoc-section__title a { + position: absolute; + right: 100%; + display: block; + opacity: 0; + width: 2rem; + padding: 0 0.4rem; +} + +.graphdoc-section__title:hover a { + opacity: 1; +} + +.graphdoc-section__title a .material-icons { + font-size: 1.2rem; +} diff --git a/backend/docs/graphdoc/styles/_override.scss b/backend/docs/graphdoc/styles/_override.scss new file mode 100644 index 00000000..a0b3a875 --- /dev/null +++ b/backend/docs/graphdoc/styles/_override.scss @@ -0,0 +1,86 @@ +.slds-scrollable::-webkit-scrollbar { + width: 10px; + height: 10px +} + +.slds-scrollable::-webkit-scrollbar:window-inactive { + opacity: 0 +} + +.slds-scrollable::-webkit-scrollbar-thumb { + background: #e0e5ee; + border-radius: .5rem; + box-shadow: #a8b7c7 0 0 0 1px inset +} + +.slds-scrollable::-webkit-scrollbar-track { + background: #a8b7c7 +} + +.slds-scrollable--y { + -webkit-overflow-scrolling: touch; + max-height: 100%; + overflow: hidden; + overflow-y: auto +} + +.slds-scrollable--y::-webkit-scrollbar { + width: 10px; + height: 10px +} + +.slds-scrollable--y::-webkit-scrollbar:window-inactive { + opacity: 0 +} + +.slds-scrollable--y::-webkit-scrollbar-thumb { + background: #e0e5ee; + border-radius: .5rem; + box-shadow: #a8b7c7 0 0 0 1px inset +} + +.slds-scrollable--y::-webkit-scrollbar-track { + background: #a8b7c7 +} + +.slds-scrollable--x { + -webkit-overflow-scrolling: touch; + max-width: 100%; + overflow: hidden; + overflow-x: auto +} + +.slds-scrollable--x::-webkit-scrollbar { + width: 10px; + height: 10px +} + +.slds-scrollable--x::-webkit-scrollbar:window-inactive { + opacity: 0 +} + +.slds-scrollable--x::-webkit-scrollbar-thumb { + background: #e0e5ee; + border-radius: .5rem; + box-shadow: #a8b7c7 0 0 0 1px inset +} + +.slds-scrollable--x::-webkit-scrollbar-track { + background: #a8b7c7 +} + +.slds-scrollable--x::-webkit-scrollbar-track, +.slds-scrollable--y::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); +} + +.slds-scrollable--x::-webkit-scrollbar-thumb, +.slds-scrollable--y::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + box-shadow: none; +} + +.material-icons.slds-button__icon { + font-size: .875rem; + vertical-align: text-bottom; +} diff --git a/backend/docs/graphdoc/styles/graphdoc.css b/backend/docs/graphdoc/styles/graphdoc.css new file mode 100644 index 00000000..ed5a797a --- /dev/null +++ b/backend/docs/graphdoc/styles/graphdoc.css @@ -0,0 +1 @@ +@font-face{font-family:'Salesforce Sans';src:url("../fonts/webfonts/SalesforceSans-Light.woff2") format("woff2"),url("../fonts/webfonts/SalesforceSans-Light.woff") format("woff");font-weight:300}@font-face{font-family:'Salesforce Sans';src:url("../fonts/webfonts/SalesforceSans-LightItalic.woff2") format("woff2"),url("../fonts/webfonts/SalesforceSans-LightItalic.woff") format("woff");font-style:italic;font-weight:300}@font-face{font-family:'Salesforce Sans';src:url("../fonts/webfonts/SalesforceSans-Regular.woff2") format("woff2"),url("../fonts/webfonts/SalesforceSans-Regular.woff") format("woff");font-weight:400}@font-face{font-family:'Salesforce Sans';src:url("../fonts/webfonts/SalesforceSans-Italic.woff2") format("woff2"),url("../fonts/webfonts/SalesforceSans-Italic.woff") format("woff");font-style:italic;font-weight:400}@font-face{font-family:'Salesforce Sans';src:url("../fonts/webfonts/SalesforceSans-Bold.woff2") format("woff2"),url("../fonts/webfonts/SalesforceSans-Bold.woff") format("woff");font-weight:700}@font-face{font-family:'Salesforce Sans';src:url("../fonts/webfonts/SalesforceSans-BoldItalic.woff2") format("woff2"),url("../fonts/webfonts/SalesforceSans-BoldItalic.woff") format("woff");font-style:italic;font-weight:700}/*! normalize.css v3.0.2 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}*,*:before,*:after{box-sizing:border-box}::placeholder{color:#54698d;font-weight:400;font-size:.8125rem}::selection{background:#d8edff;text-shadow:none;color:#16325c}html{font-family:"Salesforce Sans",Arial,sans-serif;font-size:100%;line-height:1.5;background:#fff;color:#16325c;-webkit-tap-highlight-color:transparent}body{font-size:.8125rem;background:transparent}h1,h2,h3,h4,h5,h6,p,ol,ul,dl,fieldset{margin:0;padding:0}dd,figure{margin:0}abbr[title],fieldset,hr{border:0}hr{padding:0}h1,h2,h3,h4,h5,h6{font-weight:inherit;font-size:1em}ol,ul{list-style:none}a{color:#0070d2;text-decoration:none;transition:color 0.1s linear}a:hover,a:focus{text-decoration:underline;color:#005fb2}a:active{color:#00396b}a,button{cursor:pointer}b,strong,dfn{font-weight:700}mark{background-color:#fff03f;color:#16325c}abbr[title]{cursor:help}input[type=search]{box-sizing:border-box}table{width:100%}caption,th,td{text-align:left}hr{display:block;margin:2rem 0;border-top:1px solid #d8dde6;height:1px;clear:both}audio,canvas,iframe,img,svg,video{vertical-align:middle}img{max-width:100%;height:auto}.slds-button{position:relative;display:inline-block;padding:0;background:transparent;background-clip:border-box;border:1px solid transparent;border-radius:.25rem;font-size:.75rem;line-height:1.875rem;text-decoration:none;color:#0070d2;-webkit-appearance:none;white-space:normal;user-select:none;transition:color .05s linear,background-color .05s linear}.slds-button:hover,.slds-button:focus,.slds-button:active,.slds-button:visited{text-decoration:none}.slds-button:hover,.slds-button:focus{color:#005fb2}.slds-button:focus{outline:0;box-shadow:0 0 3px #0070D2}.slds-button:active{color:#00396b}.slds-button[disabled]{color:#d8dde6}.slds-button:hover .slds-button__icon,.slds-button:focus .slds-button__icon,.slds-button:active .slds-button__icon,.slds-button[disabled] .slds-button__icon{fill:currentColor}.slds-button+.slds-button-group,.slds-button+.slds-button-group-list{margin-left:.25rem}.slds-button+.slds-button{margin-left:.25rem}.slds-button-space-left{margin-left:.25rem}a.slds-button{text-align:center}a.slds-button:focus{outline:0;box-shadow:0 0 3px #0070D2}.slds-button__icon--left{margin-right:.5rem}.slds-button__icon--right{margin-left:.5rem}.slds-button--reset{font-size:inherit;color:inherit;line-height:inherit;background:transparent;border:0;text-align:inherit}.slds-button--small{line-height:1.75rem;min-height:2rem}.slds-button--neutral{padding-left:1rem;padding-right:1rem;text-align:center;vertical-align:middle;border:1px solid #d8dde6;background-color:#fff}.slds-button--neutral:hover,.slds-button--neutral:focus{background-color:#f4f6f9}.slds-button--neutral:active{background-color:#eef1f6}.slds-button--neutral[disabled]{background-color:#fff;cursor:default}.slds-button--hint{color:#9faab5}.slds-button--hint:hover,.slds-button--hint:focus,.slds-button--hint:active{color:#0070d2}.slds-hint-parent:hover .slds-button--hint,.slds-hint-parent:focus .slds-button--hint{color:#0070d2}.slds-button--brand{padding-left:1rem;padding-right:1rem;text-align:center;vertical-align:middle;background-color:#0070d2;border:1px solid #0070d2;color:#fff}.slds-button--brand:link,.slds-button--brand:visited,.slds-button--brand:active{color:#fff}.slds-button--brand:hover,.slds-button--brand:focus{background-color:#005fb2;color:#fff}.slds-button--brand:active{background-color:#00396b}.slds-button--brand[disabled]{background:#e0e5ee;border-color:transparent;color:#fff}.slds-button--inverse{padding-left:1rem;padding-right:1rem;text-align:center;vertical-align:middle;border:1px solid #d8dde6;background-color:transparent}.slds-button--inverse:hover,.slds-button--inverse:focus{background-color:#f4f6f9}.slds-button--inverse:active{background-color:#eef1f6}.slds-button--inverse[disabled]{background-color:transparent;border-color:rgba(255,255,255,0.15)}.slds-button--inverse,.slds-button--inverse:link,.slds-button--inverse:visited,.slds-button-group .slds-button--icon-inverse,.slds-button-group .slds-button--icon-inverse:link,.slds-button-group .slds-button--icon-inverse:visited{color:#e0e5ee}.slds-button--inverse:hover,.slds-button--inverse:focus,.slds-button--inverse:active,.slds-button-group .slds-button--icon-inverse:hover,.slds-button-group .slds-button--icon-inverse:focus,.slds-button-group .slds-button--icon-inverse:active{color:#0070d2}.slds-button--inverse:focus,.slds-button-group .slds-button--icon-inverse:focus{outline:none;box-shadow:0 0 3px #E0E5EE}.slds-button--inverse[disabled],.slds-button-group .slds-button--icon-inverse[disabled]{color:rgba(255,255,255,0.15)}a.slds-button--inverse:focus{outline:none;box-shadow:0 0 3px #E0E5EE}.slds-button--destructive{padding-left:1rem;padding-right:1rem;text-align:center;vertical-align:middle;background-color:#c23934;border:1px solid #c23934;color:#fff}.slds-button--destructive:link,.slds-button--destructive:visited,.slds-button--destructive:active{color:#fff}.slds-button--destructive:hover,.slds-button--destructive:focus{background-color:#a61a14;color:#fff}.slds-button--destructive:active{background-color:#870500;border-color:#870500}.slds-button--destructive[disabled]{background:#e0e5ee;border-color:transparent;color:#fff}.slds-button--success{padding-left:1rem;padding-right:1rem;text-align:center;vertical-align:middle;background-color:#4bca81;border:1px solid #4bca81;color:#fff}.slds-button--success:link,.slds-button--success:visited,.slds-button--success:active{color:#fff}.slds-button--success:hover,.slds-button--success:focus{background-color:#04844b;color:#fff}.slds-button--success:active{background-color:#04844b;border-color:#04844b}.slds-button--neutral.slds-is-selected{border-color:transparent;background-color:transparent}.slds-button--neutral.slds-is-selected:hover:not([disabled]),.slds-button--neutral.slds-is-selected:focus:not([disabled]){border:1px solid #d8dde6;background-color:#f4f6f9}.slds-button--neutral.slds-is-selected:active{background-color:#eef1f6}.slds-button__icon--stateful{width:.75rem;height:.75rem;fill:currentColor}.slds-button--inverse.slds-is-selected{border-color:transparent}.slds-text-not-selected,.slds-text-selected,.slds-text-selected-focus,.slds-is-selected[disabled]:hover .slds-text-selected,.slds-is-selected[disabled]:focus .slds-text-selected{display:block}.slds-not-selected .slds-text-selected,.slds-not-selected .slds-text-selected-focus,.slds-is-selected .slds-text-not-selected,.slds-is-selected:not(:hover):not(:focus) .slds-text-selected-focus,.slds-is-selected[disabled]:hover .slds-text-selected-focus,.slds-is-selected:hover .slds-text-selected,.slds-is-selected:focus .slds-text-selected{display:none}.slds-button--icon,.slds-button--icon-inverse,.slds-button--icon-container,.slds-button--icon-border,.slds-button--icon-border-filled,.slds-button--icon-border-inverse,.slds-button--icon-more,.slds-button--icon-error{line-height:1;vertical-align:middle;color:#54698d}.slds-button--icon-bare{line-height:1;vertical-align:middle;color:#54698d}.slds-button--icon-border[disabled]:hover,.slds-button--icon-border[disabled]:focus{background-color:transparent}.slds-button--icon-border-filled,.slds-button--icon-border{border:1px solid #d8dde6}.slds-button--icon-border-filled:hover,.slds-button--icon-border-filled:focus,.slds-button--icon-border:hover,.slds-button--icon-border:focus{background-color:#f4f6f9}.slds-button--icon-border-filled:active,.slds-button--icon-border:active{background-color:#eef1f6}.slds-button--icon-border-inverse{border:1px solid #d8dde6}.slds-button--icon-container,.slds-button--icon-border,.slds-button--icon-border-filled,.slds-button--icon-border-inverse{width:2rem;height:2rem}.slds-button--icon-border-filled{background-color:#fff}.slds-button--icon-border-filled[disabled]{border:1px solid #d8dde6;background-color:#fff}.slds-button--icon-more{line-height:1.875rem;padding:0 .5rem;vertical-align:middle;border:1px solid #d8dde6}.slds-button--icon-more-filled{background-color:#fff}.slds-button--icon-more:hover,.slds-button--icon-more:focus{border:1px solid #d8dde6}.slds-button--icon-more:hover:hover,.slds-button--icon-more:hover:focus,.slds-button--icon-more:focus:hover,.slds-button--icon-more:focus:focus{background-color:#f4f6f9}.slds-button--icon-more:hover:active,.slds-button--icon-more:focus:active{background-color:#eef1f6}.slds-button--icon-more:hover .slds-button__icon,.slds-button--icon-more:focus .slds-button__icon{fill:#0070d2}.slds-button--icon-more:active .slds-button__icon{fill:#00396b}.slds-button--icon-more[disabled]{cursor:default}.slds-button--icon-more[disabled] .slds-button__icon{fill:#d8dde6}.slds-button--icon-container-more{line-height:1.875rem;padding:0 .5rem;vertical-align:middle}.slds-button--icon-inverse,.slds-button--icon-border-inverse{color:#fff}.slds-button--icon-inverse:hover,.slds-button--icon-inverse:focus,.slds-button--icon-border-inverse:hover,.slds-button--icon-border-inverse:focus{color:rgba(255,255,255,0.75)}.slds-button--icon-inverse:active,.slds-button--icon-border-inverse:active{color:rgba(255,255,255,0.5)}.slds-button--icon-inverse[disabled],.slds-button--icon-border-inverse[disabled]{color:rgba(255,255,255,0.15)}.slds-button--icon-error,.slds-button--icon-error:hover,.slds-button--icon-error:active,.slds-button--icon-error:focus{color:#c23934}.slds-button--icon-border-inverse[disabled]{border-color:rgba(255,255,255,0.15)}.slds-button--icon-container.slds-is-selected,.slds-button--icon-border.slds-is-selected{background-color:#0070d2;border:1px solid #0070d2;color:#fff}.slds-button--icon-container.slds-is-selected:link,.slds-button--icon-container.slds-is-selected:visited,.slds-button--icon-container.slds-is-selected:active,.slds-button--icon-border.slds-is-selected:link,.slds-button--icon-border.slds-is-selected:visited,.slds-button--icon-border.slds-is-selected:active{color:#fff}.slds-button--icon-container.slds-is-selected:hover,.slds-button--icon-container.slds-is-selected:focus,.slds-button--icon-border.slds-is-selected:hover,.slds-button--icon-border.slds-is-selected:focus{background-color:#005fb2;color:#fff}.slds-button--icon-container.slds-is-selected:active,.slds-button--icon-border.slds-is-selected:active{background-color:#00396b}.slds-button--icon-container.slds-is-selected .slds-button__icon,.slds-button--icon-border.slds-is-selected .slds-button__icon{fill:#fff}.slds-button--icon-container.slds-is-selected:hover .slds-button__icon,.slds-button--icon-container.slds-is-selected:focus .slds-button__icon,.slds-button--icon-border.slds-is-selected:hover .slds-button__icon,.slds-button--icon-border.slds-is-selected:focus .slds-button__icon{fill:#fff}.slds-button--icon-small{width:1.5rem;height:1.5rem;border-radius:.125rem}.slds-button--icon-x-small{width:1.25rem;height:1.25rem;border-radius:.125rem;line-height:1}.slds-button--icon-x-small .slds-button__icon{width:.75rem;height:.75rem}.slds-button--icon-xx-small{width:1rem;height:1rem;border-radius:.125rem;line-height:1}.slds-button--icon-xx-small .slds-button__icon{width:.5rem;height:.5rem}.slds-button__icon{width:.875rem;height:.875rem;fill:currentColor}.slds-button__icon--large{width:1.5rem;height:1.5rem}.slds-button__icon--small{width:.75rem;height:.75rem}.slds-button__icon--x-small{width:.5rem;height:.5rem}.slds-button__icon--hint{fill:#9faab5}.slds-button__icon--inverse-hint{fill:rgba(255,255,255,0.5)}.slds-hint-parent .slds-button--icon-border-inverse{border-color:rgba(255,255,255,0.5)}.slds-hint-parent .slds-button--icon-border-inverse:focus{border-color:rgba(255,255,255,0.75)}.slds-hint-parent:hover .slds-button--icon-border-inverse,.slds-hint-parent:focus .slds-button--icon-border-inverse{border-color:rgba(255,255,255,0.75)}.slds-hint-parent:hover .slds-button__icon--hint,.slds-hint-parent:focus .slds-button__icon--hint{fill:#54698d}.slds-hint-parent:hover .slds-button__icon--inverse-hint,.slds-hint-parent:focus .slds-button__icon--inverse-hint{fill:rgba(255,255,255,0.75)}.slds-form-element{position:relative}.slds-form-element__helper{font-size:.75rem}.slds-form-element__label{display:inline-block;color:#54698d;font-size:.75rem;line-height:1.5;margin-right:.75rem;margin-bottom:.25rem}.slds-form-element__label:empty{margin:0}.slds-form-element__control .slds-radio,.slds-form-element__control .slds-checkbox{display:block}.slds-form-element__icon{display:inline-block;position:relative}.slds-form-element__help{font-size:.75rem;margin-top:.5rem;display:block}.slds-form-element__static{display:inline-block;line-height:1.875rem;min-height:calc(1.875rem + 2px)}.slds-form-element__static.slds-text-longform{line-height:1.5}.slds-required{color:#c23934;margin:0 .125rem}.slds-has-error .slds-form-element__help{color:#c23934}.slds-input{background-color:#fff;color:#16325c;border:1px solid #d8dde6;border-radius:.25rem;width:100%;transition:border .1s linear,background-color .1s linear;display:inline-block;padding:0 1rem 0 .75rem;line-height:1.875rem;min-height:calc(1.875rem + (1px * 2))}.slds-input:focus,.slds-input:active{outline:0;border-color:#1589ee;background-color:#fff;box-shadow:0 0 3px #0070D2}.slds-input[disabled],.slds-input.slds-is-disabled{background-color:#e0e5ee;border-color:#a8b7c7;cursor:not-allowed;user-select:none}.slds-input[disabled]:focus,.slds-input[disabled]:active,.slds-input.slds-is-disabled:focus,.slds-input.slds-is-disabled:active{box-shadow:none}.slds-input:read-only:hover,.slds-input:read-only:focus{background-color:#f4f6f9}.slds-input:read-only:active{background-color:#eef1f6}.slds-input--bare{background-color:transparent;border:0;padding-top:0;padding-bottom:0;padding-left:.75rem;color:#16325c;line-height:1.875rem}.slds-input--bare:focus,.slds-input--bare:active{outline:0}.slds-input--height{min-height:calc(1.875rem + (1px * 2))}.slds-input-has-icon{position:relative}.slds-input-has-icon .slds-input__icon{width:1rem;height:1rem;position:absolute;top:50%;margin-top:-.5rem;fill:#9faab5}.slds-input-has-icon--left .slds-input__icon{left:1.25rem}.slds-input-has-icon--left .slds-input,.slds-input-has-icon--left .slds-input--bare{padding-left:3rem}.slds-input-has-icon--right .slds-input__icon{right:1.25rem}.slds-input-has-icon--right .slds-input,.slds-input-has-icon--right .slds-input--bare{padding-right:3rem}.slds-input-has-icon--left-right .slds-input__icon--left{left:1.25rem}.slds-input-has-icon--left-right .slds-input__icon--right{right:1.25rem}.slds-input-has-icon--left-right .slds-input,.slds-input-has-icon--left-right .slds-input--bare{padding:0 3rem}.slds-input-has-fixed-addon{display:flex}.slds-form-element__addon{display:inline-block;margin:0 .5rem;align-self:center}.slds-has-error .slds-input{background-color:#fff;border-color:#c23934;box-shadow:#c23934 0 0 0 1px inset;background-clip:padding-box}.slds-has-error .slds-input:focus,.slds-has-error .slds-input:active{box-shadow:#c23934 0 0 0 1px inset,0 0 3px #0070D2}.slds-has-error .slds-input__icon{fill:#c23934}.slds-navigation-list--vertical .slds-is-active{color:#0070d2}.slds-navigation-list--vertical .slds-is-active .slds-navigation-list--vertical__action{background-color:#f0f8fc;border-color:#d8dde6;border-left-color:#005fb2}.slds-navigation-list--vertical .slds-is-active .slds-navigation-list--vertical__action:focus{border-left-width:.5rem;color:#005fb2}.slds-navigation-list--vertical__action{display:block;border-left:.25rem solid transparent;border-top:1px solid transparent;border-bottom:1px solid transparent;padding:.5rem 1.5rem}.slds-navigation-list--vertical__action:hover,.slds-navigation-list--vertical__action:focus{outline:0;background-color:#f4f6f9}.slds-navigation-list--vertical__action:active{background-color:#eef1f6}.slds-navigation-list--vertical-inverse .slds-is-active .slds-navigation-list--vertical__action{background-color:#fff}.slds-grid{display:flex}.slds-grid--frame{min-width:100vw;min-height:100vh;overflow:hidden}.slds-grid--vertical{flex-direction:column}.slds-grid--vertical-reverse{flex-direction:column-reverse}.slds-grid--reverse{flex-direction:row-reverse}.slds-col,[class*="slds-col--padded"]{flex:1 1 auto}.slds-col--padded{padding-right:.75rem;padding-left:.75rem}.slds-col--padded-medium{padding-right:1rem;padding-left:1rem}.slds-col--padded-large{padding-right:1.5rem;padding-left:1.5rem}.slds-col--padded-around{padding:.75rem}.slds-col--padded-around-medium{padding:1rem}.slds-col--padded-around-large{padding:1.5rem}.slds-grid--pull-padded{margin-right:-.75rem;margin-left:-.75rem}.slds-grid--pull-padded-xxx-small{margin-right:-.125rem;margin-left:-.125rem}.slds-grid--pull-padded-xx-small{margin-right:-.25rem;margin-left:-.25rem}.slds-grid--pull-padded-x-small{margin-right:-.5rem;margin-left:-.5rem}.slds-grid--pull-padded-medium{margin-right:-1rem;margin-left:-1rem}.slds-grid--pull-padded-large{margin-right:-1.5rem;margin-left:-1.5rem}@media (min-width: 64em){.slds-col-rule--top{border-top:1px solid #f4f6f9}.slds-col-rule--right{border-right:1px solid #f4f6f9}.slds-col-rule--bottom{border-bottom:1px solid #f4f6f9}.slds-col-rule--left{border-left:1px solid #f4f6f9}}@media (min-width: 64em){.slds-col--rule-top{border-top:1px solid #f4f6f9}.slds-col--rule-right{border-right:1px solid #f4f6f9}.slds-col--rule-bottom{border-bottom:1px solid #f4f6f9}.slds-col--rule-left{border-left:1px solid #f4f6f9}}.slds-wrap{flex-wrap:wrap;align-items:flex-start}.slds-nowrap{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}@media (min-width: 30em){.slds-nowrap--small{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}}@media (min-width: 48em){.slds-nowrap--medium{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}}@media (min-width: 64em){.slds-nowrap--large{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}}@media (min-width: 20em){.slds-x-small-nowrap{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}}@media (min-width: 30em){.slds-small-nowrap{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}}@media (min-width: 48em){.slds-medium-nowrap{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}}@media (min-width: 64em){.slds-large-nowrap{flex:1 1 auto;flex-wrap:nowrap;align-items:stretch}}.slds-has-flexi-truncate{flex:1 1 0%;min-width:0}.slds-no-flex{flex:none}.slds-no-space{min-width:0}.slds-grow{flex-grow:1}.slds-grow-none{flex-grow:0}.slds-shrink{flex-shrink:1}.slds-shrink-none{flex-shrink:0}.slds-text-longform ul.slds-grid{margin-left:0;list-style:none}.slds-grid--align-center{justify-content:center}.slds-grid--align-center .slds-col,.slds-grid--align-center [class*="slds-col--padded"]{flex-grow:0}.slds-grid--align-space{justify-content:space-around}.slds-grid--align-space .slds-col,.slds-grid--align-space [class*="slds-col--padded"]{flex-grow:0}.slds-grid--align-spread{justify-content:space-between}.slds-grid--align-spread .slds-col,.slds-grid--align-spread [class*="slds-col--padded"]{flex-grow:0}.slds-grid--align-end{justify-content:flex-end}.slds-grid--align-end .slds-col,.slds-grid--align-end [class*="slds-col--padded"]{flex-grow:0}.slds-grid--vertical-align-start{align-items:flex-start;align-content:flex-start}.slds-grid--vertical-align-center{align-items:center;align-content:center}.slds-grid--vertical-align-end{align-items:flex-end;align-content:flex-end}.slds-align-top{vertical-align:top;align-self:flex-start}.slds-align-middle{vertical-align:middle;align-self:center}.slds-align-bottom{vertical-align:bottom;align-self:flex-end}.slds-align-content-center{flex:1;align-self:center;justify-content:center}.slds-grid--vertical-stretch{align-items:stretch;align-content:stretch}.slds-col--bump-top{margin-top:auto}.slds-col--bump-right{margin-right:auto}.slds-col--bump-bottom{margin-right:auto}.slds-col--bump-left{margin-left:auto}.slds-container--small{max-width:30rem}.slds-container--medium{max-width:48rem}.slds-container--large{max-width:64rem}.slds-container--x-large{max-width:80rem}.slds-container--fluid{width:100%}.slds-container--center{margin-left:auto;margin-right:auto}.slds-container--left{margin-right:auto}.slds-container--right{margin-left:auto}.slds-grid--overflow{flex-flow:row nowrap}.slds-grid--overflow .slds-col{min-width:11.25em;max-width:22.5em}.slds-align--absolute-center{display:flex;justify-content:center;align-content:center;align-items:center;margin:auto}.slds-media{display:flex;align-items:flex-start}.slds-media__figure{flex-shrink:0;margin-right:.75rem}.slds-media__body{flex:1;min-width:0}.slds-media__body,.slds-media__body>:last-child{margin-bottom:0}.slds-media--small .slds-media__figure{margin-right:.25rem}.slds-media--large .slds-media__figure{margin-right:1.5rem}.slds-media--small .slds-media__figure--reverse{margin-left:.25rem}.slds-media--large .slds-media__figure--reverse{margin-left:1.5rem}.slds-media--center{align-items:center}.slds-media__figure--reverse{margin:0 0 0 .75rem}.slds-media--reverse>.slds-media__figure{order:1}.slds-media--reverse.slds-media--small .slds-media__figure{margin-left:.25rem}.slds-media--double>.slds-media__figure{order:1}.slds-media--double .slds-media__figure--reverse{order:3;margin:0 0 0 1rem}.slds-media--double .slds-media__body{order:2}@media (max-width: 48em){.slds-media--responsive{display:block}.slds-media--responsive .slds-media__figure{margin:0 0 .75rem}}.slds-m-top--none{margin-top:0 !important}.slds-m-right--none{margin-right:0 !important}.slds-m-bottom--none{margin-bottom:0 !important}.slds-m-left--none{margin-left:0 !important}.slds-m-vertical--none{margin-top:0;margin-bottom:0}.slds-m-horizontal--none{margin-right:0;margin-left:0}.slds-m-around--none{margin:0}.slds-m-top--xxx-small{margin-top:.125rem}.slds-m-right--xxx-small{margin-right:.125rem}.slds-m-bottom--xxx-small{margin-bottom:.125rem}.slds-m-left--xxx-small{margin-left:.125rem}.slds-m-vertical--xxx-small{margin-top:.125rem;margin-bottom:.125rem}.slds-m-horizontal--xxx-small{margin-right:.125rem;margin-left:.125rem}.slds-m-around--xxx-small{margin:.125rem}.slds-m-top--xx-small{margin-top:.25rem}.slds-m-right--xx-small{margin-right:.25rem}.slds-m-bottom--xx-small{margin-bottom:.25rem}.slds-m-left--xx-small{margin-left:.25rem}.slds-m-vertical--xx-small{margin-top:.25rem;margin-bottom:.25rem}.slds-m-horizontal--xx-small{margin-right:.25rem;margin-left:.25rem}.slds-m-around--xx-small{margin:.25rem}.slds-m-top--x-small{margin-top:.5rem}.slds-m-right--x-small{margin-right:.5rem}.slds-m-bottom--x-small{margin-bottom:.5rem}.slds-m-left--x-small{margin-left:.5rem}.slds-m-vertical--x-small{margin-top:.5rem;margin-bottom:.5rem}.slds-m-horizontal--x-small{margin-right:.5rem;margin-left:.5rem}.slds-m-around--x-small{margin:.5rem}.slds-m-top--small{margin-top:.75rem}.slds-m-right--small{margin-right:.75rem}.slds-m-bottom--small{margin-bottom:.75rem}.slds-m-left--small{margin-left:.75rem}.slds-m-vertical--small{margin-top:.75rem;margin-bottom:.75rem}.slds-m-horizontal--small{margin-right:.75rem;margin-left:.75rem}.slds-m-around--small{margin:.75rem}.slds-m-top--medium{margin-top:1rem}.slds-m-right--medium{margin-right:1rem}.slds-m-bottom--medium{margin-bottom:1rem}.slds-m-left--medium{margin-left:1rem}.slds-m-vertical--medium{margin-top:1rem;margin-bottom:1rem}.slds-m-horizontal--medium{margin-right:1rem;margin-left:1rem}.slds-m-around--medium{margin:1rem}.slds-m-top--large{margin-top:1.5rem}.slds-m-right--large{margin-right:1.5rem}.slds-m-bottom--large{margin-bottom:1.5rem}.slds-m-left--large{margin-left:1.5rem}.slds-m-vertical--large{margin-top:1.5rem;margin-bottom:1.5rem}.slds-m-horizontal--large{margin-right:1.5rem;margin-left:1.5rem}.slds-m-around--large{margin:1.5rem}.slds-m-top--x-large{margin-top:2rem}.slds-m-right--x-large{margin-right:2rem}.slds-m-bottom--x-large{margin-bottom:2rem}.slds-m-left--x-large{margin-left:2rem}.slds-m-vertical--x-large{margin-top:2rem;margin-bottom:2rem}.slds-m-horizontal--x-large{margin-right:2rem;margin-left:2rem}.slds-m-around--x-large{margin:2rem}.slds-m-top--xx-large{margin-top:3rem}.slds-m-right--xx-large{margin-right:3rem}.slds-m-bottom--xx-large{margin-bottom:3rem}.slds-m-left--xx-large{margin-left:3rem}.slds-m-vertical--xx-large{margin-top:3rem;margin-bottom:3rem}.slds-m-horizontal--xx-large{margin-right:3rem;margin-left:3rem}.slds-m-around--xx-large{margin:3rem}.slds-m-bottom--none{margin-bottom:0}.slds-p-top--none{padding-top:0 !important}.slds-p-right--none{padding-right:0 !important}.slds-p-bottom--none{padding-bottom:0 !important}.slds-p-left--none{padding-left:0 !important}.slds-p-vertical--none{padding-top:0;padding-bottom:0}.slds-p-horizontal--none{padding-right:0;padding-left:0}.slds-p-around--none{padding:0}.slds-p-top--xxx-small{padding-top:.125rem}.slds-p-right--xxx-small{padding-right:.125rem}.slds-p-bottom--xxx-small{padding-bottom:.125rem}.slds-p-left--xxx-small{padding-left:.125rem}.slds-p-vertical--xxx-small{padding-top:.125rem;padding-bottom:.125rem}.slds-p-horizontal--xxx-small{padding-right:.125rem;padding-left:.125rem}.slds-p-around--xxx-small{padding:.125rem}.slds-p-top--xx-small{padding-top:.25rem}.slds-p-right--xx-small{padding-right:.25rem}.slds-p-bottom--xx-small{padding-bottom:.25rem}.slds-p-left--xx-small{padding-left:.25rem}.slds-p-vertical--xx-small{padding-top:.25rem;padding-bottom:.25rem}.slds-p-horizontal--xx-small{padding-right:.25rem;padding-left:.25rem}.slds-p-around--xx-small{padding:.25rem}.slds-p-top--x-small{padding-top:.5rem}.slds-p-right--x-small{padding-right:.5rem}.slds-p-bottom--x-small{padding-bottom:.5rem}.slds-p-left--x-small{padding-left:.5rem}.slds-p-vertical--x-small{padding-top:.5rem;padding-bottom:.5rem}.slds-p-horizontal--x-small{padding-right:.5rem;padding-left:.5rem}.slds-p-around--x-small{padding:.5rem}.slds-p-top--small{padding-top:.75rem}.slds-p-right--small{padding-right:.75rem}.slds-p-bottom--small{padding-bottom:.75rem}.slds-p-left--small{padding-left:.75rem}.slds-p-vertical--small{padding-top:.75rem;padding-bottom:.75rem}.slds-p-horizontal--small{padding-right:.75rem;padding-left:.75rem}.slds-p-around--small{padding:.75rem}.slds-p-top--medium{padding-top:1rem}.slds-p-right--medium{padding-right:1rem}.slds-p-bottom--medium{padding-bottom:1rem}.slds-p-left--medium{padding-left:1rem}.slds-p-vertical--medium{padding-top:1rem;padding-bottom:1rem}.slds-p-horizontal--medium{padding-right:1rem;padding-left:1rem}.slds-p-around--medium{padding:1rem}.slds-p-top--large{padding-top:1.5rem}.slds-p-right--large{padding-right:1.5rem}.slds-p-bottom--large{padding-bottom:1.5rem}.slds-p-left--large{padding-left:1.5rem}.slds-p-vertical--large{padding-top:1.5rem;padding-bottom:1.5rem}.slds-p-horizontal--large{padding-right:1.5rem;padding-left:1.5rem}.slds-p-around--large{padding:1.5rem}.slds-p-top--x-large{padding-top:2rem}.slds-p-right--x-large{padding-right:2rem}.slds-p-bottom--x-large{padding-bottom:2rem}.slds-p-left--x-large{padding-left:2rem}.slds-p-vertical--x-large{padding-top:2rem;padding-bottom:2rem}.slds-p-horizontal--x-large{padding-right:2rem;padding-left:2rem}.slds-p-around--x-large{padding:2rem}.slds-p-top--xx-large{padding-top:3rem}.slds-p-right--xx-large{padding-right:3rem}.slds-p-bottom--xx-large{padding-bottom:3rem}.slds-p-left--xx-large{padding-left:3rem}.slds-p-vertical--xx-large{padding-top:3rem;padding-bottom:3rem}.slds-p-horizontal--xx-large{padding-right:3rem;padding-left:3rem}.slds-p-around--xx-large{padding:3rem}.slds-float--left{float:left}.slds-float--right{float:right}.slds-float--none{float:none}.slds-clearfix:after{content:'';display:table;clear:both}.slds-clear{clear:both}.slds-text-link--reset{cursor:pointer;line-height:inherit;font-size:inherit}.slds-text-link--reset:active{outline:none}.slds-text-link--reset,.slds-text-link--reset:active,.slds-text-link--reset:focus,.slds-text-link--reset:hover{color:inherit;text-decoration:inherit}.slds-text-link{color:#0070d2;text-decoration:none;transition:color 0.1s linear}.slds-text-link:hover,.slds-text-link:focus{text-decoration:underline;color:#005fb2}.slds-text-link:active{color:#00396b}.slds-has-blur-focus{color:currentColor}.slds-has-blur-focus:hover,.slds-has-blur-focus:focus,.slds-has-blur-focus:active{color:currentColor;text-decoration:none}.slds-has-blur-focus:focus{outline:0;box-shadow:0 0 3px #0070D2}.slds-type-focus{border-bottom:1px solid transparent;border-radius:0;color:currentColor;cursor:pointer}.slds-type-focus:hover,.slds-type-focus:focus{color:currentColor;border-bottom:1px solid currentColor}.slds-has-block-links a{display:block;text-decoration:none}.slds-has-block-links .slds-is-nested{margin-left:1rem}.slds-has-block-links--space .slds-list__item,.slds-has-block-links--space .slds-item{padding:0}.slds-has-block-links--space a{display:block;text-decoration:none;padding:.75rem}@media (min-width: 48em){.slds-has-block-links--space a{padding:.5rem}}.slds-has-inline-block-links a{display:inline-block;text-decoration:none}.slds-has-inline-block-links--space a{display:inline-block;text-decoration:none;padding:.75rem}@media (min-width: 48em){.slds-has-inline-block-links--space a{padding:.5rem}}.slds-list--vertical-space .slds-list__item+.slds-list__item,.slds-list--vertical-space .slds-item+.slds-item{margin-top:.5rem}.slds-list--vertical-space-medium .slds-list__item+.slds-list__item,.slds-list--vertical-space-medium .slds-item+.slds-item{margin-top:1rem}.slds-is-nested{margin-left:1rem}.slds-list--dotted{margin-left:1.5rem;list-style:disc}.slds-list--ordered{margin-left:1.5rem;list-style:decimal}.slds-has-dividers--top>.slds-list__item,.slds-has-dividers--top>.slds-item{border-top:1px solid #d8dde6}.slds-has-dividers--top-space>.slds-list__item,.slds-has-dividers--top-space>.slds-item{border-top:1px solid #d8dde6;padding:.75rem}@media (min-width: 30em){.slds-has-dividers--top-space>.slds-list__item,.slds-has-dividers--top-space>.slds-item{padding:.5rem}}.slds-has-dividers--bottom>.slds-list__item,.slds-has-dividers--bottom>.slds-item{border-bottom:1px solid #d8dde6}.slds-has-dividers--bottom-space>.slds-list__item,.slds-has-dividers--bottom-space>.slds-item{border-bottom:1px solid #d8dde6;padding:.75rem}@media (min-width: 30em){.slds-has-dividers--bottom-space>.slds-list__item,.slds-has-dividers--bottom-space>.slds-item{padding:.5rem}}.slds-has-dividers--around>.slds-item{border:1px solid #d8dde6;border-radius:.25rem;background-clip:padding-box}.slds-has-dividers--around>.slds-item+.slds-item{margin-top:.5rem}.slds-has-dividers--around-space>.slds-item{border:1px solid #d8dde6;border-radius:.25rem;background-clip:padding-box;padding:.75rem}@media (min-width: 30em){.slds-has-dividers--around-space>.slds-item{padding:.5rem}}.slds-has-dividers--around-space>.slds-item+.slds-item{margin-top:.5rem}.slds-has-list-interactions>.slds-list__item:hover,.slds-has-list-interactions>.slds-item:hover{background-color:#f4f6f9;border-color:#d8dde6;cursor:pointer}.slds-has-list-interactions>.slds-list__item:active,.slds-has-list-interactions>.slds-item:active{background-color:#eef1f6;box-shadow:#d8dde6 0 -1px 0 inset}.slds-has-list-interactions>.slds-list__item.slds-is-selected,.slds-has-list-interactions>.slds-item.slds-is-selected{box-shadow:#0070d2 0 0 0 1px inset;background-color:#f0f8fc}.slds-has-list-interactions>.slds-list__item.slds-is-selected:hover,.slds-has-list-interactions>.slds-list__item.slds-is-selected:focus,.slds-has-list-interactions>.slds-item.slds-is-selected:hover,.slds-has-list-interactions>.slds-item.slds-is-selected:focus{box-shadow:#1589ee 0 -2px 0 inset,#1589ee 0 0 0 1px inset}.slds-list--vertical.slds-has-dividers>.slds-list__item{padding:.5rem;border-bottom:1px solid #d8dde6}.slds-list--vertical.slds-has-dividers>.slds-list__item:hover{background-color:#f4f6f9;border-color:#d8dde6;cursor:pointer}.slds-list--vertical.slds-has-dividers>.slds-list__item:active{background-color:#eef1f6;box-shadow:#d8dde6 0 -1px 0 inset}.slds-list--vertical.slds-has-dividers>.slds-list__item.slds-is-selected{box-shadow:#0070d2 0 0 0 1px inset;background-color:#f0f8fc}.slds-list--vertical.slds-has-dividers>.slds-list__item.slds-is-selected:hover,.slds-list--vertical.slds-has-dividers>.slds-list__item.slds-is-selected:focus{box-shadow:#1589ee 0 -2px 0 inset,#1589ee 0 0 0 1px inset}.slds-has-cards>.slds-list__item{border:1px solid #d8dde6;border-radius:.25rem;background-clip:padding-box}.slds-has-cards>.slds-list__item+.slds-list__item{margin-top:.5rem}.slds-has-cards--space>.slds-list__item{border:1px solid #d8dde6;border-radius:.25rem;background-clip:padding-box;padding:.75rem}@media (min-width: 30em){.slds-has-cards--space>.slds-list__item{padding:.5rem}}.slds-has-cards--space>.slds-list__item+.slds-list__item{margin-top:.5rem}.slds-list--horizontal{display:flex}.slds-list--horizontal>.slds-list__item{align-self:center}.slds-list--horizontal-large>.slds-list__item>a{padding:.75rem 1rem}.slds-has-dividers--left>.slds-list__item,.slds-has-dividers--left>.slds-item{position:relative;display:flex;align-items:center}.slds-has-dividers--left>.slds-list__item:before,.slds-has-dividers--left>.slds-item:before{width:2px;height:2px;content:'';display:inline-block;vertical-align:middle;margin-left:.5rem;margin-right:.5rem;border-radius:50%;background-color:#16325c}.slds-has-dividers--left>.slds-list__item:first-child,.slds-has-dividers--left>.slds-item:first-child{margin-right:0;padding-right:0}.slds-has-dividers--left>.slds-list__item:first-child:before,.slds-has-dividers--left>.slds-item:first-child:before{content:none}.slds-has-dividers--right>.slds-list__item,.slds-has-dividers--right>.slds-item{position:relative;display:flex;align-items:center}.slds-has-dividers--right>.slds-list__item:after,.slds-has-dividers--right>.slds-item:after{width:2px;height:2px;content:'';margin-left:.5rem;margin-right:.5rem;border-radius:50%;background-color:#16325c}.slds-has-dividers--right>.slds-list__item:last-child,.slds-has-dividers--right>.slds-item:last-child{margin-right:0;padding-right:0}.slds-has-dividers--right>.slds-list__item:last-child:after,.slds-has-dividers--right>.slds-item:last-child:after{content:none}.slds-list--horizontal.slds-has-dividers>.slds-list__item{position:relative;display:flex;align-items:center}.slds-list--horizontal.slds-has-dividers>.slds-list__item:after{width:2px;height:2px;content:'';margin-left:.5rem;margin-right:.5rem;border-radius:50%;background-color:#16325c}.slds-list--horizontal.slds-has-dividers>.slds-list__item:last-child{margin-right:0;padding-right:0}.slds-list--horizontal.slds-has-dividers>.slds-list__item:last-child:after{content:none}.slds-has-divider{margin-top:.5rem;padding-top:.5rem;border-top:1px solid #d8dde6}.slds-has-divider--top{border-top:1px solid #d8dde6}.slds-has-divider--top-space{border-top:1px solid #d8dde6;margin-top:.5rem;padding-top:.5rem}.slds-has-divider--right{position:relative;display:flex;align-items:center}.slds-has-divider--right:after{width:2px;height:2px;content:'';margin-left:.5rem;margin-right:.5rem;border-radius:50%;background-color:#16325c}.slds-has-divider--right:last-child{margin-right:0;padding-right:0}.slds-has-divider--right:last-child:after{content:none}.slds-has-divider--bottom{border-bottom:1px solid #d8dde6}.slds-has-divider--bottom-space{border-bottom:1px solid #d8dde6;margin-bottom:.5rem;padding-bottom:.5rem}.slds-has-divider--left{position:relative;display:flex;align-items:center}.slds-has-divider--left:before{width:2px;height:2px;content:'';display:inline-block;vertical-align:middle;margin-left:.5rem;margin-right:.5rem;border-radius:50%;background-color:#16325c}.slds-has-divider--left:first-child{margin-right:0;padding-right:0}.slds-has-divider--left:first-child:before{content:none}.slds-dl--inline:after{content:'';display:table;clear:both}@media (min-width: 48em){.slds-dl--inline__label{float:left;clear:left}.slds-dl--inline__detail{float:left;padding-left:.25rem}}@media (min-width: 48em){.slds-dl--horizontal{flex-wrap:wrap;align-items:flex-start;display:flex}.slds-dl--horizontal__label{width:30%;padding-right:.75rem}.slds-dl--horizontal__detail{width:70%}}.slds-list--horizontal .slds-item--label{width:30%;padding-right:.75rem}.slds-list--horizontal .slds-item--detail{width:70%}.slds-list--vertical .slds-item--label,.slds-list--vertical .slds-item--detail{display:block}.slds-list--inline{display:inline-flex;max-width:100%}.slds-list--inline .slds-item--label{max-width:180px;padding-right:.75rem;flex-shrink:0}.slds-list--inline .slds-item--label ~ .slds-item--label{padding-left:1rem}.slds-list--inline .slds-item--detail{min-width:0}.slds-border--bottom{border-bottom:1px solid #d8dde6}.slds-border--left{border-left:1px solid #d8dde6}.slds-border--right{border-right:1px solid #d8dde6}.slds-border--top{border-top:1px solid #d8dde6}.slds-truncate{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.slds-truncate_container--25{max-width:25%}.slds-truncate_container--50{max-width:50%}.slds-truncate_container--75{max-width:75%}.slds-truncate_container--33{max-width:33%}.slds-truncate_container--66{max-width:66%}.slds-hyphenate{overflow-wrap:break-word;word-wrap:break-word;hyphens:auto}.slds-box{padding:1rem;border-radius:.25rem;border:1px solid #d8dde6}.slds-box--xx-small{padding:.25rem}.slds-box--x-small{padding:.5rem}.slds-box--small{padding:.75rem}.slds-box--border{padding:0;border-radius:.25rem;border:1px solid #d8dde6}.slds-theme--default{background-color:#fff;color:#16325c}.slds-theme--shade{background-color:#f4f6f9}.slds-theme--inverse{background-color:#061c3f;color:#fff;border-color:#061c3f}.slds-theme--inverse a:not(.slds-button--neutral){color:#fff;text-decoration:underline}.slds-theme--inverse a:not(.slds-button--neutral):link,.slds-theme--inverse a:not(.slds-button--neutral):visited{color:#fff}.slds-theme--inverse a:not(.slds-button--neutral):hover,.slds-theme--inverse a:not(.slds-button--neutral):focus{color:rgba(255,255,255,0.75)}.slds-theme--inverse a:not(.slds-button--neutral):active{color:rgba(255,255,255,0.5)}.slds-theme--inverse a:not(.slds-button--neutral)[disabled]{color:rgba(255,255,255,0.15)}.slds-theme--alt-inverse{background-color:#16325c;color:#fff;border-color:#16325c}.slds-theme--alt-inverse a:not(.slds-button--neutral){color:#fff;text-decoration:underline}.slds-theme--alt-inverse a:not(.slds-button--neutral):link,.slds-theme--alt-inverse a:not(.slds-button--neutral):visited{color:#fff}.slds-theme--alt-inverse a:not(.slds-button--neutral):hover,.slds-theme--alt-inverse a:not(.slds-button--neutral):focus{color:rgba(255,255,255,0.75)}.slds-theme--alt-inverse a:not(.slds-button--neutral):active{color:rgba(255,255,255,0.5)}.slds-theme--alt-inverse a:not(.slds-button--neutral)[disabled]{color:rgba(255,255,255,0.15)}.slds-theme--success{color:#fff;background-color:rgba(4,132,75,0.95)}.slds-theme--success a:not(.slds-button--neutral){color:#fff;text-decoration:underline}.slds-theme--success a:not(.slds-button--neutral):link,.slds-theme--success a:not(.slds-button--neutral):visited{color:#fff}.slds-theme--success a:not(.slds-button--neutral):hover,.slds-theme--success a:not(.slds-button--neutral):focus{color:rgba(255,255,255,0.75)}.slds-theme--success a:not(.slds-button--neutral):active{color:rgba(255,255,255,0.5)}.slds-theme--success a:not(.slds-button--neutral)[disabled]{color:rgba(255,255,255,0.15)}.slds-theme--info{color:#fff;background-color:rgba(84,105,141,0.95)}.slds-theme--info a:not(.slds-button--neutral){color:#fff;text-decoration:underline}.slds-theme--info a:not(.slds-button--neutral):link,.slds-theme--info a:not(.slds-button--neutral):visited{color:#fff}.slds-theme--info a:not(.slds-button--neutral):hover,.slds-theme--info a:not(.slds-button--neutral):focus{color:rgba(255,255,255,0.75)}.slds-theme--info a:not(.slds-button--neutral):active{color:rgba(255,255,255,0.5)}.slds-theme--info a:not(.slds-button--neutral)[disabled]{color:rgba(255,255,255,0.15)}.slds-theme--warning{background-color:#ffb75d;color:#16325c}.slds-theme--warning .slds-button__icon{fill:#54698d}.slds-theme--error{color:#fff;background-color:rgba(194,57,52,0.95)}.slds-theme--error a:not(.slds-button--neutral){color:#fff;text-decoration:underline}.slds-theme--error a:not(.slds-button--neutral):link,.slds-theme--error a:not(.slds-button--neutral):visited{color:#fff}.slds-theme--error a:not(.slds-button--neutral):hover,.slds-theme--error a:not(.slds-button--neutral):focus{color:rgba(255,255,255,0.75)}.slds-theme--error a:not(.slds-button--neutral):active{color:rgba(255,255,255,0.5)}.slds-theme--error a:not(.slds-button--neutral)[disabled]{color:rgba(255,255,255,0.15)}.slds-theme--offline{color:#fff;background-color:#444}.slds-theme--offline a:not(.slds-button--neutral){color:#fff;text-decoration:underline}.slds-theme--offline a:not(.slds-button--neutral):link,.slds-theme--offline a:not(.slds-button--neutral):visited{color:#fff}.slds-theme--offline a:not(.slds-button--neutral):hover,.slds-theme--offline a:not(.slds-button--neutral):focus{color:rgba(255,255,255,0.75)}.slds-theme--offline a:not(.slds-button--neutral):active{color:rgba(255,255,255,0.5)}.slds-theme--offline a:not(.slds-button--neutral)[disabled]{color:rgba(255,255,255,0.15)}.slds-theme--alert-texture{background-image:linear-gradient(45deg, rgba(0,0,0,0.025) 25%, transparent 25%, transparent 50%, rgba(0,0,0,0.025) 50%, rgba(0,0,0,0.025) 75%, transparent 75%, transparent);background-size:64px 64px}.slds-theme--inverse-text{color:#fff}.slds-theme--inverse-text a:not(.slds-button--neutral){color:#fff;text-decoration:underline}.slds-theme--inverse-text a:not(.slds-button--neutral):link,.slds-theme--inverse-text a:not(.slds-button--neutral):visited{color:#fff}.slds-theme--inverse-text a:not(.slds-button--neutral):hover,.slds-theme--inverse-text a:not(.slds-button--neutral):focus{color:rgba(255,255,255,0.75)}.slds-theme--inverse-text a:not(.slds-button--neutral):active{color:rgba(255,255,255,0.5)}.slds-theme--inverse-text a:not(.slds-button--neutral)[disabled]{color:rgba(255,255,255,0.15)}.slds-theme--default .slds-text-body--small,.slds-theme--shade .slds-text-body--small,.slds-theme--inverse .slds-text-body--small,.slds-theme--alt-inverse .slds-text-body--small,.slds-theme--success .slds-text-body--small,.slds-theme--info .slds-text-body--small,.slds-theme--warning .slds-text-body--small,.slds-theme--error .slds-text-body--small,.slds-theme--offline .slds-text-body--small,.slds-theme--alert-texture .slds-text-body--small,.slds-theme--inverse-text .slds-text-body--small{color:inherit}.slds-text-body--regular{font-size:.8125rem}.slds-text-body--small{font-size:.75rem}.slds-text-heading--large{font-weight:300;font-size:1.75rem;line-height:1.25}.slds-text-heading--medium{font-weight:300;font-size:1.25rem;line-height:1.25}.slds-text-heading--small{font-size:1rem;line-height:1.25}.slds-text-heading--label{font-size:.75rem;line-height:1.25;color:#54698d;text-transform:uppercase;letter-spacing:0.0625rem}.slds-text-heading--label-normal{font-size:.75rem;line-height:1.25;color:#54698d}.slds-text-title{font-size:.75rem;line-height:1.25;color:#54698d}.slds-text-title--caps{font-size:.75rem;line-height:1.25;color:#54698d;text-transform:uppercase;letter-spacing:0.0625rem}.slds-line-height--reset{line-height:1}.slds-text-color--default{color:#16325c}.slds-text-color--weak{color:#54698d}.slds-text-color--error{color:#c23934}.slds-text-color--inverse{color:#fff}.slds-text-color--inverse-weak{color:#9faab5}.slds-text-align--left{text-align:left}.slds-text-align--center{text-align:center}.slds-text-align--right{text-align:right}.slds-text-longform h1,.slds-text-longform h2,.slds-text-longform h3,.slds-text-longform p,.slds-text-longform ul,.slds-text-longform ol,.slds-text-longform dl,.slds-text-longform img{margin-bottom:.75rem}.slds-text-longform ul{margin-left:1.5rem;list-style:disc}.slds-text-longform ul ul{list-style:circle}.slds-text-longform ul ul ul{list-style:square}.slds-text-longform ol{margin-left:1.5rem;list-style:decimal}.slds-text-longform ol ol{list-style:lower-alpha}.slds-text-longform ol ol ol{list-style:lower-roman}.slds-text-longform .slds-video{display:block;max-width:100%}.slds-text-longform .slds-video.slds-video--center{margin:0 auto}.slds-text-longform .slds-video.slds-video--right{margin:0 0 0 auto}.slds-section{margin-top:.5rem;margin-bottom:.5rem}.slds-section__title{display:flex;align-items:center;font-size:1rem}.slds-section__title-action{display:flex;align-items:center;background:#f4f6f9;cursor:pointer;width:100%;text-align:left;color:currentColor;font-size:inherit;padding:0 .5rem}.slds-section__title-action:hover,.slds-section__title-action:focus,.slds-section__title-action:active{background:#eef1f6;color:inherit}.slds-section__content{visibility:hidden;opacity:0;height:0}.slds-section__title-action-icon{transform:rotate(-90deg)}.slds-section.slds-is-open .slds-section__title-action-icon{transform:rotate(0deg);transform-origin:45%}.slds-section.slds-is-open .slds-section__content{padding-top:.75rem;visibility:visible;opacity:1;height:auto}.slds-section-title{font-size:1rem}.slds-section-title>a{display:inline-block;color:#16325c}.slds-section-title>a:hover,.slds-section-title>a:focus{color:#005fb2}.slds-section-title>a:focus{box-shadow:0 0 3px #0070D2}.slds-section-title>a:active{color:#16325c}.slds-section-title .slds-icon{width:1rem;height:1rem;fill:currentColor}.slds-section-title .slds-section-group--is-closed .slds-icon{transform:rotate(-90deg)}.slds-section-title--divider{font-size:.75rem;line-height:1.25;color:#54698d;text-transform:uppercase;letter-spacing:0.0625rem;padding:.75rem 1rem;background:#f4f6f9}.slds-scrollable{-webkit-overflow-scrolling:touch;overflow:auto}.slds-scrollable--none{overflow:hidden}.slds-scrollable--y{-webkit-overflow-scrolling:touch;max-height:100%;overflow:hidden;overflow-y:auto}.slds-scrollable--x{-webkit-overflow-scrolling:touch;max-width:100%;overflow:hidden;overflow-x:auto}.slds-size--xx-small{width:6rem}.slds-size--x-small{width:12rem}.slds-size--small{width:15rem}.slds-size--medium{width:20rem}.slds-size--large{width:25rem}.slds-size--x-large{width:40rem}.slds-size--xx-large{width:60rem}.slds-size--1-of-1{width:100%}.slds-size--1-of-2{width:50%}.slds-size--2-of-2{width:100%}.slds-size--1-of-3{width:33.33333%}.slds-size--2-of-3{width:66.66667%}.slds-size--3-of-3{width:100%}.slds-size--1-of-4{width:25%}.slds-size--2-of-4{width:50%}.slds-size--3-of-4{width:75%}.slds-size--4-of-4{width:100%}.slds-size--1-of-5{width:20%}.slds-size--2-of-5{width:40%}.slds-size--3-of-5{width:60%}.slds-size--4-of-5{width:80%}.slds-size--5-of-5{width:100%}.slds-size--1-of-6{width:16.66667%}.slds-size--2-of-6{width:33.33333%}.slds-size--3-of-6{width:50%}.slds-size--4-of-6{width:66.66667%}.slds-size--5-of-6{width:83.33333%}.slds-size--6-of-6{width:100%}.slds-size--1-of-7{width:14.28571%}.slds-size--2-of-7{width:28.57143%}.slds-size--3-of-7{width:42.85714%}.slds-size--4-of-7{width:57.14286%}.slds-size--5-of-7{width:71.42857%}.slds-size--6-of-7{width:85.71429%}.slds-size--7-of-7{width:100%}.slds-size--1-of-8{width:12.5%}.slds-size--2-of-8{width:25%}.slds-size--3-of-8{width:37.5%}.slds-size--4-of-8{width:50%}.slds-size--5-of-8{width:62.5%}.slds-size--6-of-8{width:75%}.slds-size--7-of-8{width:87.5%}.slds-size--8-of-8{width:100%}.slds-size--1-of-12{width:8.33333%}.slds-size--2-of-12{width:16.66667%}.slds-size--3-of-12{width:25%}.slds-size--4-of-12{width:33.33333%}.slds-size--5-of-12{width:41.66667%}.slds-size--6-of-12{width:50%}.slds-size--7-of-12{width:58.33333%}.slds-size--8-of-12{width:66.66667%}.slds-size--9-of-12{width:75%}.slds-size--10-of-12{width:83.33333%}.slds-size--11-of-12{width:91.66667%}.slds-size--12-of-12{width:100%}.slds-order--1{order:1}.slds-order--2{order:2}.slds-order--3{order:3}.slds-order--4{order:4}.slds-order--5{order:5}.slds-order--6{order:6}.slds-order--7{order:7}.slds-order--8{order:8}.slds-order--9{order:9}.slds-order--10{order:10}.slds-order--11{order:11}.slds-order--12{order:12}@media (min-width: 20em){.slds-x-small-size--xx-small{width:6rem}.slds-x-small-size--x-small{width:12rem}.slds-x-small-size--small{width:15rem}.slds-x-small-size--medium{width:20rem}.slds-x-small-size--large{width:25rem}.slds-x-small-size--x-large{width:40rem}.slds-x-small-size--xx-large{width:60rem}.slds-x-small-size--1-of-1{width:100%}.slds-x-small-size--1-of-2{width:50%}.slds-x-small-size--2-of-2{width:100%}.slds-x-small-size--1-of-3{width:33.33333%}.slds-x-small-size--2-of-3{width:66.66667%}.slds-x-small-size--3-of-3{width:100%}.slds-x-small-size--1-of-4{width:25%}.slds-x-small-size--2-of-4{width:50%}.slds-x-small-size--3-of-4{width:75%}.slds-x-small-size--4-of-4{width:100%}.slds-x-small-size--1-of-5{width:20%}.slds-x-small-size--2-of-5{width:40%}.slds-x-small-size--3-of-5{width:60%}.slds-x-small-size--4-of-5{width:80%}.slds-x-small-size--5-of-5{width:100%}.slds-x-small-size--1-of-6{width:16.66667%}.slds-x-small-size--2-of-6{width:33.33333%}.slds-x-small-size--3-of-6{width:50%}.slds-x-small-size--4-of-6{width:66.66667%}.slds-x-small-size--5-of-6{width:83.33333%}.slds-x-small-size--6-of-6{width:100%}.slds-x-small-size--1-of-7{width:14.28571%}.slds-x-small-size--2-of-7{width:28.57143%}.slds-x-small-size--3-of-7{width:42.85714%}.slds-x-small-size--4-of-7{width:57.14286%}.slds-x-small-size--5-of-7{width:71.42857%}.slds-x-small-size--6-of-7{width:85.71429%}.slds-x-small-size--7-of-7{width:100%}.slds-x-small-size--1-of-8{width:12.5%}.slds-x-small-size--2-of-8{width:25%}.slds-x-small-size--3-of-8{width:37.5%}.slds-x-small-size--4-of-8{width:50%}.slds-x-small-size--5-of-8{width:62.5%}.slds-x-small-size--6-of-8{width:75%}.slds-x-small-size--7-of-8{width:87.5%}.slds-x-small-size--8-of-8{width:100%}.slds-x-small-size--1-of-12{width:8.33333%}.slds-x-small-size--2-of-12{width:16.66667%}.slds-x-small-size--3-of-12{width:25%}.slds-x-small-size--4-of-12{width:33.33333%}.slds-x-small-size--5-of-12{width:41.66667%}.slds-x-small-size--6-of-12{width:50%}.slds-x-small-size--7-of-12{width:58.33333%}.slds-x-small-size--8-of-12{width:66.66667%}.slds-x-small-size--9-of-12{width:75%}.slds-x-small-size--10-of-12{width:83.33333%}.slds-x-small-size--11-of-12{width:91.66667%}.slds-x-small-size--12-of-12{width:100%}.slds-x-small-order--1{order:1}.slds-x-small-order--2{order:2}.slds-x-small-order--3{order:3}.slds-x-small-order--4{order:4}.slds-x-small-order--5{order:5}.slds-x-small-order--6{order:6}.slds-x-small-order--7{order:7}.slds-x-small-order--8{order:8}.slds-x-small-order--9{order:9}.slds-x-small-order--10{order:10}.slds-x-small-order--11{order:11}.slds-x-small-order--12{order:12}}@media (max-width: 20em){.slds-max-x-small-size--xx-small{width:6rem}.slds-max-x-small-size--x-small{width:12rem}.slds-max-x-small-size--small{width:15rem}.slds-max-x-small-size--medium{width:20rem}.slds-max-x-small-size--large{width:25rem}.slds-max-x-small-size--x-large{width:40rem}.slds-max-x-small-size--xx-large{width:60rem}.slds-max-x-small-size--1-of-1{width:100%}.slds-max-x-small-size--1-of-2{width:50%}.slds-max-x-small-size--2-of-2{width:100%}.slds-max-x-small-size--1-of-3{width:33.33333%}.slds-max-x-small-size--2-of-3{width:66.66667%}.slds-max-x-small-size--3-of-3{width:100%}.slds-max-x-small-size--1-of-4{width:25%}.slds-max-x-small-size--2-of-4{width:50%}.slds-max-x-small-size--3-of-4{width:75%}.slds-max-x-small-size--4-of-4{width:100%}.slds-max-x-small-size--1-of-5{width:20%}.slds-max-x-small-size--2-of-5{width:40%}.slds-max-x-small-size--3-of-5{width:60%}.slds-max-x-small-size--4-of-5{width:80%}.slds-max-x-small-size--5-of-5{width:100%}.slds-max-x-small-size--1-of-6{width:16.66667%}.slds-max-x-small-size--2-of-6{width:33.33333%}.slds-max-x-small-size--3-of-6{width:50%}.slds-max-x-small-size--4-of-6{width:66.66667%}.slds-max-x-small-size--5-of-6{width:83.33333%}.slds-max-x-small-size--6-of-6{width:100%}.slds-max-x-small-size--1-of-7{width:14.28571%}.slds-max-x-small-size--2-of-7{width:28.57143%}.slds-max-x-small-size--3-of-7{width:42.85714%}.slds-max-x-small-size--4-of-7{width:57.14286%}.slds-max-x-small-size--5-of-7{width:71.42857%}.slds-max-x-small-size--6-of-7{width:85.71429%}.slds-max-x-small-size--7-of-7{width:100%}.slds-max-x-small-size--1-of-8{width:12.5%}.slds-max-x-small-size--2-of-8{width:25%}.slds-max-x-small-size--3-of-8{width:37.5%}.slds-max-x-small-size--4-of-8{width:50%}.slds-max-x-small-size--5-of-8{width:62.5%}.slds-max-x-small-size--6-of-8{width:75%}.slds-max-x-small-size--7-of-8{width:87.5%}.slds-max-x-small-size--8-of-8{width:100%}.slds-max-x-small-size--1-of-12{width:8.33333%}.slds-max-x-small-size--2-of-12{width:16.66667%}.slds-max-x-small-size--3-of-12{width:25%}.slds-max-x-small-size--4-of-12{width:33.33333%}.slds-max-x-small-size--5-of-12{width:41.66667%}.slds-max-x-small-size--6-of-12{width:50%}.slds-max-x-small-size--7-of-12{width:58.33333%}.slds-max-x-small-size--8-of-12{width:66.66667%}.slds-max-x-small-size--9-of-12{width:75%}.slds-max-x-small-size--10-of-12{width:83.33333%}.slds-max-x-small-size--11-of-12{width:91.66667%}.slds-max-x-small-size--12-of-12{width:100%}.slds-max-x-small-order--1{order:1}.slds-max-x-small-order--2{order:2}.slds-max-x-small-order--3{order:3}.slds-max-x-small-order--4{order:4}.slds-max-x-small-order--5{order:5}.slds-max-x-small-order--6{order:6}.slds-max-x-small-order--7{order:7}.slds-max-x-small-order--8{order:8}.slds-max-x-small-order--9{order:9}.slds-max-x-small-order--10{order:10}.slds-max-x-small-order--11{order:11}.slds-max-x-small-order--12{order:12}}@media (min-width: 30em){.slds-small-size--xx-small{width:6rem}.slds-small-size--x-small{width:12rem}.slds-small-size--small{width:15rem}.slds-small-size--medium{width:20rem}.slds-small-size--large{width:25rem}.slds-small-size--x-large{width:40rem}.slds-small-size--xx-large{width:60rem}.slds-small-size--1-of-1{width:100%}.slds-small-size--1-of-2{width:50%}.slds-small-size--2-of-2{width:100%}.slds-small-size--1-of-3{width:33.33333%}.slds-small-size--2-of-3{width:66.66667%}.slds-small-size--3-of-3{width:100%}.slds-small-size--1-of-4{width:25%}.slds-small-size--2-of-4{width:50%}.slds-small-size--3-of-4{width:75%}.slds-small-size--4-of-4{width:100%}.slds-small-size--1-of-5{width:20%}.slds-small-size--2-of-5{width:40%}.slds-small-size--3-of-5{width:60%}.slds-small-size--4-of-5{width:80%}.slds-small-size--5-of-5{width:100%}.slds-small-size--1-of-6{width:16.66667%}.slds-small-size--2-of-6{width:33.33333%}.slds-small-size--3-of-6{width:50%}.slds-small-size--4-of-6{width:66.66667%}.slds-small-size--5-of-6{width:83.33333%}.slds-small-size--6-of-6{width:100%}.slds-small-size--1-of-7{width:14.28571%}.slds-small-size--2-of-7{width:28.57143%}.slds-small-size--3-of-7{width:42.85714%}.slds-small-size--4-of-7{width:57.14286%}.slds-small-size--5-of-7{width:71.42857%}.slds-small-size--6-of-7{width:85.71429%}.slds-small-size--7-of-7{width:100%}.slds-small-size--1-of-8{width:12.5%}.slds-small-size--2-of-8{width:25%}.slds-small-size--3-of-8{width:37.5%}.slds-small-size--4-of-8{width:50%}.slds-small-size--5-of-8{width:62.5%}.slds-small-size--6-of-8{width:75%}.slds-small-size--7-of-8{width:87.5%}.slds-small-size--8-of-8{width:100%}.slds-small-size--1-of-12{width:8.33333%}.slds-small-size--2-of-12{width:16.66667%}.slds-small-size--3-of-12{width:25%}.slds-small-size--4-of-12{width:33.33333%}.slds-small-size--5-of-12{width:41.66667%}.slds-small-size--6-of-12{width:50%}.slds-small-size--7-of-12{width:58.33333%}.slds-small-size--8-of-12{width:66.66667%}.slds-small-size--9-of-12{width:75%}.slds-small-size--10-of-12{width:83.33333%}.slds-small-size--11-of-12{width:91.66667%}.slds-small-size--12-of-12{width:100%}.slds-small-order--1{order:1}.slds-small-order--2{order:2}.slds-small-order--3{order:3}.slds-small-order--4{order:4}.slds-small-order--5{order:5}.slds-small-order--6{order:6}.slds-small-order--7{order:7}.slds-small-order--8{order:8}.slds-small-order--9{order:9}.slds-small-order--10{order:10}.slds-small-order--11{order:11}.slds-small-order--12{order:12}}@media (max-width: 30em){.slds-max-small-size--xx-small{width:6rem}.slds-max-small-size--x-small{width:12rem}.slds-max-small-size--small{width:15rem}.slds-max-small-size--medium{width:20rem}.slds-max-small-size--large{width:25rem}.slds-max-small-size--x-large{width:40rem}.slds-max-small-size--xx-large{width:60rem}.slds-max-small-size--1-of-1{width:100%}.slds-max-small-size--1-of-2{width:50%}.slds-max-small-size--2-of-2{width:100%}.slds-max-small-size--1-of-3{width:33.33333%}.slds-max-small-size--2-of-3{width:66.66667%}.slds-max-small-size--3-of-3{width:100%}.slds-max-small-size--1-of-4{width:25%}.slds-max-small-size--2-of-4{width:50%}.slds-max-small-size--3-of-4{width:75%}.slds-max-small-size--4-of-4{width:100%}.slds-max-small-size--1-of-5{width:20%}.slds-max-small-size--2-of-5{width:40%}.slds-max-small-size--3-of-5{width:60%}.slds-max-small-size--4-of-5{width:80%}.slds-max-small-size--5-of-5{width:100%}.slds-max-small-size--1-of-6{width:16.66667%}.slds-max-small-size--2-of-6{width:33.33333%}.slds-max-small-size--3-of-6{width:50%}.slds-max-small-size--4-of-6{width:66.66667%}.slds-max-small-size--5-of-6{width:83.33333%}.slds-max-small-size--6-of-6{width:100%}.slds-max-small-size--1-of-7{width:14.28571%}.slds-max-small-size--2-of-7{width:28.57143%}.slds-max-small-size--3-of-7{width:42.85714%}.slds-max-small-size--4-of-7{width:57.14286%}.slds-max-small-size--5-of-7{width:71.42857%}.slds-max-small-size--6-of-7{width:85.71429%}.slds-max-small-size--7-of-7{width:100%}.slds-max-small-size--1-of-8{width:12.5%}.slds-max-small-size--2-of-8{width:25%}.slds-max-small-size--3-of-8{width:37.5%}.slds-max-small-size--4-of-8{width:50%}.slds-max-small-size--5-of-8{width:62.5%}.slds-max-small-size--6-of-8{width:75%}.slds-max-small-size--7-of-8{width:87.5%}.slds-max-small-size--8-of-8{width:100%}.slds-max-small-size--1-of-12{width:8.33333%}.slds-max-small-size--2-of-12{width:16.66667%}.slds-max-small-size--3-of-12{width:25%}.slds-max-small-size--4-of-12{width:33.33333%}.slds-max-small-size--5-of-12{width:41.66667%}.slds-max-small-size--6-of-12{width:50%}.slds-max-small-size--7-of-12{width:58.33333%}.slds-max-small-size--8-of-12{width:66.66667%}.slds-max-small-size--9-of-12{width:75%}.slds-max-small-size--10-of-12{width:83.33333%}.slds-max-small-size--11-of-12{width:91.66667%}.slds-max-small-size--12-of-12{width:100%}.slds-max-small-order--1{order:1}.slds-max-small-order--2{order:2}.slds-max-small-order--3{order:3}.slds-max-small-order--4{order:4}.slds-max-small-order--5{order:5}.slds-max-small-order--6{order:6}.slds-max-small-order--7{order:7}.slds-max-small-order--8{order:8}.slds-max-small-order--9{order:9}.slds-max-small-order--10{order:10}.slds-max-small-order--11{order:11}.slds-max-small-order--12{order:12}}@media (min-width: 48em){.slds-medium-size--xx-small{width:6rem}.slds-medium-size--x-small{width:12rem}.slds-medium-size--small{width:15rem}.slds-medium-size--medium{width:20rem}.slds-medium-size--large{width:25rem}.slds-medium-size--x-large{width:40rem}.slds-medium-size--xx-large{width:60rem}.slds-medium-size--1-of-1{width:100%}.slds-medium-size--1-of-2{width:50%}.slds-medium-size--2-of-2{width:100%}.slds-medium-size--1-of-3{width:33.33333%}.slds-medium-size--2-of-3{width:66.66667%}.slds-medium-size--3-of-3{width:100%}.slds-medium-size--1-of-4{width:25%}.slds-medium-size--2-of-4{width:50%}.slds-medium-size--3-of-4{width:75%}.slds-medium-size--4-of-4{width:100%}.slds-medium-size--1-of-5{width:20%}.slds-medium-size--2-of-5{width:40%}.slds-medium-size--3-of-5{width:60%}.slds-medium-size--4-of-5{width:80%}.slds-medium-size--5-of-5{width:100%}.slds-medium-size--1-of-6{width:16.66667%}.slds-medium-size--2-of-6{width:33.33333%}.slds-medium-size--3-of-6{width:50%}.slds-medium-size--4-of-6{width:66.66667%}.slds-medium-size--5-of-6{width:83.33333%}.slds-medium-size--6-of-6{width:100%}.slds-medium-size--1-of-7{width:14.28571%}.slds-medium-size--2-of-7{width:28.57143%}.slds-medium-size--3-of-7{width:42.85714%}.slds-medium-size--4-of-7{width:57.14286%}.slds-medium-size--5-of-7{width:71.42857%}.slds-medium-size--6-of-7{width:85.71429%}.slds-medium-size--7-of-7{width:100%}.slds-medium-size--1-of-8{width:12.5%}.slds-medium-size--2-of-8{width:25%}.slds-medium-size--3-of-8{width:37.5%}.slds-medium-size--4-of-8{width:50%}.slds-medium-size--5-of-8{width:62.5%}.slds-medium-size--6-of-8{width:75%}.slds-medium-size--7-of-8{width:87.5%}.slds-medium-size--8-of-8{width:100%}.slds-medium-size--1-of-12{width:8.33333%}.slds-medium-size--2-of-12{width:16.66667%}.slds-medium-size--3-of-12{width:25%}.slds-medium-size--4-of-12{width:33.33333%}.slds-medium-size--5-of-12{width:41.66667%}.slds-medium-size--6-of-12{width:50%}.slds-medium-size--7-of-12{width:58.33333%}.slds-medium-size--8-of-12{width:66.66667%}.slds-medium-size--9-of-12{width:75%}.slds-medium-size--10-of-12{width:83.33333%}.slds-medium-size--11-of-12{width:91.66667%}.slds-medium-size--12-of-12{width:100%}.slds-medium-order--1{order:1}.slds-medium-order--2{order:2}.slds-medium-order--3{order:3}.slds-medium-order--4{order:4}.slds-medium-order--5{order:5}.slds-medium-order--6{order:6}.slds-medium-order--7{order:7}.slds-medium-order--8{order:8}.slds-medium-order--9{order:9}.slds-medium-order--10{order:10}.slds-medium-order--11{order:11}.slds-medium-order--12{order:12}}@media (max-width: 48em){.slds-max-medium-size--xx-small{width:6rem}.slds-max-medium-size--x-small{width:12rem}.slds-max-medium-size--small{width:15rem}.slds-max-medium-size--medium{width:20rem}.slds-max-medium-size--large{width:25rem}.slds-max-medium-size--x-large{width:40rem}.slds-max-medium-size--xx-large{width:60rem}.slds-max-medium-size--1-of-1{width:100%}.slds-max-medium-size--1-of-2{width:50%}.slds-max-medium-size--2-of-2{width:100%}.slds-max-medium-size--1-of-3{width:33.33333%}.slds-max-medium-size--2-of-3{width:66.66667%}.slds-max-medium-size--3-of-3{width:100%}.slds-max-medium-size--1-of-4{width:25%}.slds-max-medium-size--2-of-4{width:50%}.slds-max-medium-size--3-of-4{width:75%}.slds-max-medium-size--4-of-4{width:100%}.slds-max-medium-size--1-of-5{width:20%}.slds-max-medium-size--2-of-5{width:40%}.slds-max-medium-size--3-of-5{width:60%}.slds-max-medium-size--4-of-5{width:80%}.slds-max-medium-size--5-of-5{width:100%}.slds-max-medium-size--1-of-6{width:16.66667%}.slds-max-medium-size--2-of-6{width:33.33333%}.slds-max-medium-size--3-of-6{width:50%}.slds-max-medium-size--4-of-6{width:66.66667%}.slds-max-medium-size--5-of-6{width:83.33333%}.slds-max-medium-size--6-of-6{width:100%}.slds-max-medium-size--1-of-7{width:14.28571%}.slds-max-medium-size--2-of-7{width:28.57143%}.slds-max-medium-size--3-of-7{width:42.85714%}.slds-max-medium-size--4-of-7{width:57.14286%}.slds-max-medium-size--5-of-7{width:71.42857%}.slds-max-medium-size--6-of-7{width:85.71429%}.slds-max-medium-size--7-of-7{width:100%}.slds-max-medium-size--1-of-8{width:12.5%}.slds-max-medium-size--2-of-8{width:25%}.slds-max-medium-size--3-of-8{width:37.5%}.slds-max-medium-size--4-of-8{width:50%}.slds-max-medium-size--5-of-8{width:62.5%}.slds-max-medium-size--6-of-8{width:75%}.slds-max-medium-size--7-of-8{width:87.5%}.slds-max-medium-size--8-of-8{width:100%}.slds-max-medium-size--1-of-12{width:8.33333%}.slds-max-medium-size--2-of-12{width:16.66667%}.slds-max-medium-size--3-of-12{width:25%}.slds-max-medium-size--4-of-12{width:33.33333%}.slds-max-medium-size--5-of-12{width:41.66667%}.slds-max-medium-size--6-of-12{width:50%}.slds-max-medium-size--7-of-12{width:58.33333%}.slds-max-medium-size--8-of-12{width:66.66667%}.slds-max-medium-size--9-of-12{width:75%}.slds-max-medium-size--10-of-12{width:83.33333%}.slds-max-medium-size--11-of-12{width:91.66667%}.slds-max-medium-size--12-of-12{width:100%}.slds-max-medium-order--1{order:1}.slds-max-medium-order--2{order:2}.slds-max-medium-order--3{order:3}.slds-max-medium-order--4{order:4}.slds-max-medium-order--5{order:5}.slds-max-medium-order--6{order:6}.slds-max-medium-order--7{order:7}.slds-max-medium-order--8{order:8}.slds-max-medium-order--9{order:9}.slds-max-medium-order--10{order:10}.slds-max-medium-order--11{order:11}.slds-max-medium-order--12{order:12}}@media (min-width: 64em){.slds-large-size--xx-small{width:6rem}.slds-large-size--x-small{width:12rem}.slds-large-size--small{width:15rem}.slds-large-size--medium{width:20rem}.slds-large-size--large{width:25rem}.slds-large-size--x-large{width:40rem}.slds-large-size--xx-large{width:60rem}.slds-large-size--1-of-1{width:100%}.slds-large-size--1-of-2{width:50%}.slds-large-size--2-of-2{width:100%}.slds-large-size--1-of-3{width:33.33333%}.slds-large-size--2-of-3{width:66.66667%}.slds-large-size--3-of-3{width:100%}.slds-large-size--1-of-4{width:25%}.slds-large-size--2-of-4{width:50%}.slds-large-size--3-of-4{width:75%}.slds-large-size--4-of-4{width:100%}.slds-large-size--1-of-5{width:20%}.slds-large-size--2-of-5{width:40%}.slds-large-size--3-of-5{width:60%}.slds-large-size--4-of-5{width:80%}.slds-large-size--5-of-5{width:100%}.slds-large-size--1-of-6{width:16.66667%}.slds-large-size--2-of-6{width:33.33333%}.slds-large-size--3-of-6{width:50%}.slds-large-size--4-of-6{width:66.66667%}.slds-large-size--5-of-6{width:83.33333%}.slds-large-size--6-of-6{width:100%}.slds-large-size--1-of-7{width:14.28571%}.slds-large-size--2-of-7{width:28.57143%}.slds-large-size--3-of-7{width:42.85714%}.slds-large-size--4-of-7{width:57.14286%}.slds-large-size--5-of-7{width:71.42857%}.slds-large-size--6-of-7{width:85.71429%}.slds-large-size--7-of-7{width:100%}.slds-large-size--1-of-8{width:12.5%}.slds-large-size--2-of-8{width:25%}.slds-large-size--3-of-8{width:37.5%}.slds-large-size--4-of-8{width:50%}.slds-large-size--5-of-8{width:62.5%}.slds-large-size--6-of-8{width:75%}.slds-large-size--7-of-8{width:87.5%}.slds-large-size--8-of-8{width:100%}.slds-large-size--1-of-12{width:8.33333%}.slds-large-size--2-of-12{width:16.66667%}.slds-large-size--3-of-12{width:25%}.slds-large-size--4-of-12{width:33.33333%}.slds-large-size--5-of-12{width:41.66667%}.slds-large-size--6-of-12{width:50%}.slds-large-size--7-of-12{width:58.33333%}.slds-large-size--8-of-12{width:66.66667%}.slds-large-size--9-of-12{width:75%}.slds-large-size--10-of-12{width:83.33333%}.slds-large-size--11-of-12{width:91.66667%}.slds-large-size--12-of-12{width:100%}.slds-large-order--1{order:1}.slds-large-order--2{order:2}.slds-large-order--3{order:3}.slds-large-order--4{order:4}.slds-large-order--5{order:5}.slds-large-order--6{order:6}.slds-large-order--7{order:7}.slds-large-order--8{order:8}.slds-large-order--9{order:9}.slds-large-order--10{order:10}.slds-large-order--11{order:11}.slds-large-order--12{order:12}}@media (max-width: 64em){.slds-max-large-size--xx-small{width:6rem}.slds-max-large-size--x-small{width:12rem}.slds-max-large-size--small{width:15rem}.slds-max-large-size--medium{width:20rem}.slds-max-large-size--large{width:25rem}.slds-max-large-size--x-large{width:40rem}.slds-max-large-size--xx-large{width:60rem}.slds-max-large-size--1-of-1{width:100%}.slds-max-large-size--1-of-2{width:50%}.slds-max-large-size--2-of-2{width:100%}.slds-max-large-size--1-of-3{width:33.33333%}.slds-max-large-size--2-of-3{width:66.66667%}.slds-max-large-size--3-of-3{width:100%}.slds-max-large-size--1-of-4{width:25%}.slds-max-large-size--2-of-4{width:50%}.slds-max-large-size--3-of-4{width:75%}.slds-max-large-size--4-of-4{width:100%}.slds-max-large-size--1-of-5{width:20%}.slds-max-large-size--2-of-5{width:40%}.slds-max-large-size--3-of-5{width:60%}.slds-max-large-size--4-of-5{width:80%}.slds-max-large-size--5-of-5{width:100%}.slds-max-large-size--1-of-6{width:16.66667%}.slds-max-large-size--2-of-6{width:33.33333%}.slds-max-large-size--3-of-6{width:50%}.slds-max-large-size--4-of-6{width:66.66667%}.slds-max-large-size--5-of-6{width:83.33333%}.slds-max-large-size--6-of-6{width:100%}.slds-max-large-size--1-of-7{width:14.28571%}.slds-max-large-size--2-of-7{width:28.57143%}.slds-max-large-size--3-of-7{width:42.85714%}.slds-max-large-size--4-of-7{width:57.14286%}.slds-max-large-size--5-of-7{width:71.42857%}.slds-max-large-size--6-of-7{width:85.71429%}.slds-max-large-size--7-of-7{width:100%}.slds-max-large-size--1-of-8{width:12.5%}.slds-max-large-size--2-of-8{width:25%}.slds-max-large-size--3-of-8{width:37.5%}.slds-max-large-size--4-of-8{width:50%}.slds-max-large-size--5-of-8{width:62.5%}.slds-max-large-size--6-of-8{width:75%}.slds-max-large-size--7-of-8{width:87.5%}.slds-max-large-size--8-of-8{width:100%}.slds-max-large-size--1-of-12{width:8.33333%}.slds-max-large-size--2-of-12{width:16.66667%}.slds-max-large-size--3-of-12{width:25%}.slds-max-large-size--4-of-12{width:33.33333%}.slds-max-large-size--5-of-12{width:41.66667%}.slds-max-large-size--6-of-12{width:50%}.slds-max-large-size--7-of-12{width:58.33333%}.slds-max-large-size--8-of-12{width:66.66667%}.slds-max-large-size--9-of-12{width:75%}.slds-max-large-size--10-of-12{width:83.33333%}.slds-max-large-size--11-of-12{width:91.66667%}.slds-max-large-size--12-of-12{width:100%}.slds-max-large-order--1{order:1}.slds-max-large-order--2{order:2}.slds-max-large-order--3{order:3}.slds-max-large-order--4{order:4}.slds-max-large-order--5{order:5}.slds-max-large-order--6{order:6}.slds-max-large-order--7{order:7}.slds-max-large-order--8{order:8}.slds-max-large-order--9{order:9}.slds-max-large-order--10{order:10}.slds-max-large-order--11{order:11}.slds-max-large-order--12{order:12}}.slds-is-relative{position:relative}.slds-is-static{position:static}.slds-is-fixed{position:fixed}.slds-is-absolute{position:absolute}.slds-hide{display:none}.slds-show{display:block}.slds-show--inline-block{display:inline-block}.slds-show--inline{display:inline}.slds-hidden{visibility:hidden}.slds-visible{visibility:visible}.slds-transition-hide{opacity:0}.slds-transition-show{opacity:1}.slds-is-collapsed{height:0;overflow:hidden}.slds-collapsed{height:0;overflow:hidden}.slds-is-expanded{height:auto;overflow:visible}.slds-expanded{height:auto;overflow:visible}.slds-assistive-text{position:absolute !important;margin:-1px !important;border:0 !important;padding:0 !important;width:1px !important;height:1px !important;overflow:hidden !important;clip:rect(0 0 0 0) !important}.slds-assistive-text--focus:focus{margin:inherit !important;border:inherit !important;padding:inherit !important;width:auto !important;height:auto !important;overflow:visible !important;clip:auto !important}.slds-x-small-show{display:none}@media (min-width: 320px){.slds-x-small-show{display:block}.slds-x-small-show--inline-block{display:inline-block}.slds-x-small-show--inline{display:inline}}.slds-x-small-show-only{display:none}@media (min-width: 320px) and (max-width: 479px){.slds-x-small-show-only{display:block}.slds-x-small-show-only--inline-block{display:inline-block}.slds-x-small-show-only--inline{display:inline}}@media (max-width: 479px){.slds-max-x-small-hide{display:none}}.slds-small-show{display:none}@media (min-width: 480px){.slds-small-show{display:block}.slds-small-show--inline-block{display:inline-block}.slds-small-show--inline{display:inline}}.slds-small-show-only{display:none}@media (min-width: 480px) and (max-width: 767px){.slds-small-show-only{display:block}.slds-small-show-only--inline-block{display:inline-block}.slds-small-show-only--inline{display:inline}}@media (max-width: 767px){.slds-max-small-hide{display:none}}.slds-medium-show{display:none}@media (min-width: 768px){.slds-medium-show{display:block}.slds-medium-show--inline-block{display:inline-block}.slds-medium-show--inline{display:inline}}.slds-medium-show-only{display:none}@media (min-width: 768px) and (max-width: 1023px){.slds-medium-show-only{display:block}.slds-medium-show-only--inline-block{display:inline-block}.slds-medium-show-only--inline{display:inline}}@media (max-width: 1023px){.slds-max-medium-hide{display:none}}.slds-large-show{display:none}@media (min-width: 1024px){.slds-large-show{display:block}.slds-large-show--inline-block{display:inline-block}.slds-large-show--inline{display:inline}}.slds-scrollable::-webkit-scrollbar{width:10px;height:10px}.slds-scrollable::-webkit-scrollbar:window-inactive{opacity:0}.slds-scrollable::-webkit-scrollbar-thumb{background:#e0e5ee;border-radius:.5rem;box-shadow:#a8b7c7 0 0 0 1px inset}.slds-scrollable::-webkit-scrollbar-track{background:#a8b7c7}.slds-scrollable--y{-webkit-overflow-scrolling:touch;max-height:100%;overflow:hidden;overflow-y:auto}.slds-scrollable--y::-webkit-scrollbar{width:10px;height:10px}.slds-scrollable--y::-webkit-scrollbar:window-inactive{opacity:0}.slds-scrollable--y::-webkit-scrollbar-thumb{background:#e0e5ee;border-radius:.5rem;box-shadow:#a8b7c7 0 0 0 1px inset}.slds-scrollable--y::-webkit-scrollbar-track{background:#a8b7c7}.slds-scrollable--x{-webkit-overflow-scrolling:touch;max-width:100%;overflow:hidden;overflow-x:auto}.slds-scrollable--x::-webkit-scrollbar{width:10px;height:10px}.slds-scrollable--x::-webkit-scrollbar:window-inactive{opacity:0}.slds-scrollable--x::-webkit-scrollbar-thumb{background:#e0e5ee;border-radius:.5rem;box-shadow:#a8b7c7 0 0 0 1px inset}.slds-scrollable--x::-webkit-scrollbar-track{background:#a8b7c7}.slds-scrollable--x::-webkit-scrollbar-track,.slds-scrollable--y::-webkit-scrollbar-track{background:rgba(255,255,255,0.1)}.slds-scrollable--x::-webkit-scrollbar-thumb,.slds-scrollable--y::-webkit-scrollbar-thumb{background:rgba(0,0,0,0.1);box-shadow:none}.material-icons.slds-button__icon{font-size:.875rem;vertical-align:text-bottom}html{height:100%}body{min-height:100%;display:block;padding:0;margin:0}nav{left:0;top:0;width:30%;max-width:20rem;padding:0;background:white;z-index:100;position:fixed;height:100%;overflow:auto;box-sizing:border-box;box-shadow:0 2px 3px 0 rgba(0,0,0,0.16);transform:translateX(0);transition:.2s transform ease}@media (max-width: 768px){nav{transform:translateX(-100%);width:20rem}nav.is-active{transform:translateX(0)}}nav sup{font-style:italic;opacity:.7}header a{text-decoration:none !important}main section,main footer{width:100%;padding:0 0 0 30%;position:relative}@media (max-width: 768px){main section,main footer{padding-left:0}}@media (min-width: 70rem){main section,main footer{padding-left:21rem}}main .title,main footer{padding-bottom:3rem}main .title{background:#005fb2}main .title .slds-text-title--caps,main .title .slds-button{color:inherit}main .container{box-sizing:border-box;padding:.75rem 1.5rem;max-width:60rem}@media (max-width: 768px){main .container{overflow:auto}}main code.highlight{display:block;background:#4D4D4C;position:relative;box-shadow:0 4px 4px 0 rgba(0,0,0,0.16);padding:.125rem;padding-top:1.5rem;border-radius:.25rem;min-width:50rem}main code.highlight::after,main code.highlight::before{content:"";display:block;height:.75rem;width:.75rem;position:absolute;top:.5rem;border-radius:100%;cursor:pointer}main code.highlight::before{background:#c23934;left:.5rem}main code.highlight::after{background:#676765;left:1.75rem}@media (min-width: 768px){.less-than-medium{display:none}}@media (min-width: 480px){.less-than-small{display:none}}.graphdoc-section__title{border-bottom:1px solid #d8dde6;margin-bottom:1.5rem;position:relative}.graphdoc-section__title a{position:absolute;right:100%;display:block;opacity:0;width:2rem;padding:0 0.4rem}.graphdoc-section__title:hover a{opacity:1}.graphdoc-section__title a .material-icons{font-size:1.2rem} diff --git a/backend/docs/graphdoc/styles/graphdoc.scss b/backend/docs/graphdoc/styles/graphdoc.scss new file mode 100644 index 00000000..97a65345 --- /dev/null +++ b/backend/docs/graphdoc/styles/graphdoc.scss @@ -0,0 +1,180 @@ +@import + '../../../node_modules/@salesforce-ux/design-system/scss/init', + '../../../node_modules/@salesforce-ux/design-system/scss/vendor/normalize'; + +@include core($scoped: false, $globals: true); + +@import + // Activity Timeline + // '../../../node_modules/@salesforce-ux/design-system/scss/components/activity-timeline/index', + + // // Badges + // '../../../node_modules/@salesforce-ux/design-system/scss/components/badges/index', + + // // Breadcrumbs + // '../../../node_modules/@salesforce-ux/design-system/scss/components/breadcrumbs/index', + + // // Button Groups + // '../../../node_modules/@salesforce-ux/design-system/scss/components/button-groups/index', + + // // Button + '../../../node_modules/@salesforce-ux/design-system/scss/components/buttons/flavors/base/index', + '../../../node_modules/@salesforce-ux/design-system/scss/components/buttons/flavors/stateful/index', + + // // Button Icon + '../../../node_modules/@salesforce-ux/design-system/scss/components/button-icons/flavors/base/index', + + // // Icons + // '../../../node_modules/@salesforce-ux/design-system/scss/components/icons/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/icons/flavors/icon-colors/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/icons/flavors/sizes/index', + + // // Card + // '../../../node_modules/@salesforce-ux/design-system/scss/components/cards/index', + + // // Forms + '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/index', + '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/input/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/textarea/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/radio/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/radio-group-alternate/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/checkbox/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/checkbox-toggle/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/select/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/checkbox-alternate/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/checkbox-add-button/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/multi-select/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/horizontal-form/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/stacked-form/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/inline-form/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/compound-form/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/forms/flavors/docked-form-footer/index', + + // // File selector + // '../../../node_modules/@salesforce-ux/design-system/scss/components/file-selector/index', + + // // Page Headers + // '../../../node_modules/@salesforce-ux/design-system/scss/components/page-headers/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/page-headers/flavors/record-home/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/page-headers/flavors/record-home-vertical/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/page-headers/flavors/object-home/index', + + // // Panels + // '../../../node_modules/@salesforce-ux/design-system/scss/components/panels/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/panels/flavors/filtering/index', + + // // Popovers + // '../../../node_modules/@salesforce-ux/design-system/scss/components/popovers/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/popovers/flavors/panels/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/popovers/flavors/nubbins/index', + + // // Walkthrough + // '../../../node_modules/@salesforce-ux/design-system/scss/components/walkthrough/flavors/popovers/index', + + // // Tooltips + // '../../../node_modules/@salesforce-ux/design-system/scss/components/tooltips/index', + + // // Menus + // '../../../node_modules/@salesforce-ux/design-system/scss/components/menus/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/menus/flavors/action-overflow/index', + + // // Picklist + // '../../../node_modules/@salesforce-ux/design-system/scss/components/picklist/index', + + // // Datepicker + // '../../../node_modules/@salesforce-ux/design-system/scss/components/datepickers/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/datepickers/flavors/time/index', + + // // Docked Composer + // '../../../node_modules/@salesforce-ux/design-system/scss/components/docked-composer/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/docked-composer/flavors/email/index', + + // // Docked Utility Bar + // '../../../node_modules/@salesforce-ux/design-system/scss/components/docked-utility-bar/flavors/utility-bar/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/docked-utility-bar/flavors/utility-panel/index', + + // // Global Header + // '../../../node_modules/@salesforce-ux/design-system/scss/components/global-header/flavors/base/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/global-header/flavors/notifications/index', + + // // Global Navigation + // '../../../node_modules/@salesforce-ux/design-system/scss/components/global-navigation/index', + + // // Publishers + // '../../../node_modules/@salesforce-ux/design-system/scss/components/publishers/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/publishers/flavors/discussion-feed/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/publishers/flavors/comment/index', + + // // Discussion Feed + // '../../../node_modules/@salesforce-ux/design-system/scss/components/feeds/flavors/feed-list/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/feeds/flavors/comment/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/feeds/flavors/post/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/feeds/flavors/post-with-attachments/index', + + // // Modal + // '../../../node_modules/@salesforce-ux/design-system/scss/components/modals/index', + + // // App Launcher + // '../../../node_modules/@salesforce-ux/design-system/scss/components/app-launcher/index', + + // // Pills + // '../../../node_modules/@salesforce-ux/design-system/scss/components/pills/index', + + // // Process + // '../../../node_modules/@salesforce-ux/design-system/scss/components/process/flavors/wizard/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/process/flavors/sales-path-coach/index', + + // // Progress Indicator + // '../../../node_modules/@salesforce-ux/design-system/scss/components/progress-indicator/index', + + // // Rich Text Editor + // '../../../node_modules/@salesforce-ux/design-system/scss/components/rich-text-editor/index', + + // // Spinners + // '../../../node_modules/@salesforce-ux/design-system/scss/components/spinners/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/spinners/flavors/sizes/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/spinners/flavors/colors/index', + + // // Split view list + // '../../../node_modules/@salesforce-ux/design-system/scss/components/split-view/index', + + // // Title + // '../../../node_modules/@salesforce-ux/design-system/scss/components/tiles/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/tiles/flavors/board/index', + + // // Tabs + // '../../../node_modules/@salesforce-ux/design-system/scss/components/tabs/flavors/default/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/tabs/flavors/scoped/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/tabs/flavors/scrolling/index', + + // // Navigation + '../../../node_modules/@salesforce-ux/design-system/scss/components/navigation/flavors/vertical/index', + + // // Notifications -- Toasts & Alerts + // '../../../node_modules/@salesforce-ux/design-system/scss/components/notifications/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/notifications/flavors/alert/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/notifications/flavors/prompt/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/notifications/flavors/modal-toast/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/walkthrough/flavors/header/index', + + // // Lookup + // '../../../node_modules/@salesforce-ux/design-system/scss/components/lookups/index', + + // // Images + // '../../../node_modules/@salesforce-ux/design-system/scss/components/images/flavors/avatar/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/images/flavors/figure/index', + + // // Tables + // '../../../node_modules/@salesforce-ux/design-system/scss/components/data-tables/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/data-tables/flavors/responsive/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/data-tables/flavors/inline-edit/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/data-tables/flavors/fixed-header/index', + + // // Tree (Order matters) + // '../../../node_modules/@salesforce-ux/design-system/scss/components/trees/flavors/base/index', + // '../../../node_modules/@salesforce-ux/design-system/scss/components/trees/flavors/grid/index', + + '../../../node_modules/@salesforce-ux/design-system/scss/utilities/index', + + './override', + './custom'; diff --git a/backend/docs/graphdoc/swapemails.doc.html b/backend/docs/graphdoc/swapemails.doc.html new file mode 100644 index 00000000..78053836 --- /dev/null +++ b/backend/docs/graphdoc/swapemails.doc.html @@ -0,0 +1,483 @@ + + + + + + + + + + SwapEmails + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

SwapEmails

+

Swap between primary and secondary emails.

+

Require password confirmation.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/totalemission.doc.html b/backend/docs/graphdoc/totalemission.doc.html new file mode 100644 index 00000000..2da7d7d8 --- /dev/null +++ b/backend/docs/graphdoc/totalemission.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + TotalEmission + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

TotalEmission

+

GraphQL total emissions

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type TotalEmission {
  • # Name of the working group
  • workingGroupName: String
  • # Name of the institution the working group belongs to
  • workingGroupInstitutionName: String
  • # Total CO2e emissions [tco2e]
  • co2e: Float
  • # CO2e emissions per capita [tco2e]
  • co2eCap: Float
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/type.spec.html b/backend/docs/graphdoc/type.spec.html new file mode 100644 index 00000000..52d1841d --- /dev/null +++ b/backend/docs/graphdoc/type.spec.html @@ -0,0 +1,487 @@ + + + + + + + + + + __Type + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

__Type

+

The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the __TypeKind enum.

+

Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/typekind.spec.html b/backend/docs/graphdoc/typekind.spec.html new file mode 100644 index 00000000..7cc6f0e9 --- /dev/null +++ b/backend/docs/graphdoc/typekind.spec.html @@ -0,0 +1,486 @@ + + + + + + + + + + __TypeKind + + + + +
+
+
+ +
+
+ +
+

ENUM

+

__TypeKind

+

An enum describing what kind of type a given __Type is

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • enum __TypeKind {
  • # Indicates this type is a scalar.
  • SCALAR
  • # Indicates this type is an object. `fields` and `interfaces` are valid fields.
  • OBJECT
  • # Indicates this type is an interface. `fields` and `possibleTypes` are valid
  • # fields.
  • INTERFACE
  • # Indicates this type is a union. `possibleTypes` is a valid field.
  • UNION
  • # Indicates this type is an enum. `enumValues` is a valid field.
  • ENUM
  • # Indicates this type is an input object. `inputFields` is a valid field.
  • INPUT_OBJECT
  • # Indicates this type is a list. `ofType` is a valid field.
  • LIST
  • # Indicates this type is a non-null. `ofType` is a valid field.
  • NON_NULL
  • }
+
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/updateaccount.doc.html b/backend/docs/graphdoc/updateaccount.doc.html new file mode 100644 index 00000000..d8eaffc7 --- /dev/null +++ b/backend/docs/graphdoc/updateaccount.doc.html @@ -0,0 +1,483 @@ + + + + + + + + + + UpdateAccount + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

UpdateAccount

+

Update user model fields, defined on settings.

+

User must be verified.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/usernode.doc.html b/backend/docs/graphdoc/usernode.doc.html new file mode 100644 index 00000000..30ca7f09 --- /dev/null +++ b/backend/docs/graphdoc/usernode.doc.html @@ -0,0 +1,501 @@ + + + + + + + + + + UserNode + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

UserNode

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/usernodeconnection.doc.html b/backend/docs/graphdoc/usernodeconnection.doc.html new file mode 100644 index 00000000..11ae4d95 --- /dev/null +++ b/backend/docs/graphdoc/usernodeconnection.doc.html @@ -0,0 +1,481 @@ + + + + + + + + + + UserNodeConnection + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

UserNodeConnection

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type UserNodeConnection {
  • # Pagination data for this connection.
  • pageInfo: PageInfo!
  • # Contains the nodes in this connection.
  • edges: [UserNodeEdge]!
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/usernodeedge.doc.html b/backend/docs/graphdoc/usernodeedge.doc.html new file mode 100644 index 00000000..7bf87fcc --- /dev/null +++ b/backend/docs/graphdoc/usernodeedge.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + UserNodeEdge + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

UserNodeEdge

+

A Relay edge containing a UserNode and its cursor.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • type UserNodeEdge {
  • # The item at the end of the edge
  • node: UserNode
  • # A cursor for use in pagination
  • cursor: String!
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/usertype.doc.html b/backend/docs/graphdoc/usertype.doc.html new file mode 100644 index 00000000..e13caff0 --- /dev/null +++ b/backend/docs/graphdoc/usertype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + UserType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

UserType

+

GraphQL User Type

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphdoc/verifyaccount.doc.html b/backend/docs/graphdoc/verifyaccount.doc.html new file mode 100644 index 00000000..117d110c --- /dev/null +++ b/backend/docs/graphdoc/verifyaccount.doc.html @@ -0,0 +1,485 @@ + + + + + + + + + + VerifyAccount + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

VerifyAccount

+

Verify user account.

+

Receive the token that was sent by email. +If the token is valid, make the user verified +by making the user.status.verified field true.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/verifysecondaryemail.doc.html b/backend/docs/graphdoc/verifysecondaryemail.doc.html new file mode 100644 index 00000000..6d6c6a7a --- /dev/null +++ b/backend/docs/graphdoc/verifysecondaryemail.doc.html @@ -0,0 +1,490 @@ + + + + + + + + + + VerifySecondaryEmail + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

VerifySecondaryEmail

+

Verify user secondary email.

+

Receive the token that was sent by email. +User is already verified when using this mutation.

+

If the token is valid, add the secondary email +to user.status.secondary_email field.

+

Note that until the secondary email is verified, +it has not been saved anywhere beyond the token, +so it can still be used to create a new account. +After being verified, it will no longer be available.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/verifytoken.doc.html b/backend/docs/graphdoc/verifytoken.doc.html new file mode 100644 index 00000000..a176bd59 --- /dev/null +++ b/backend/docs/graphdoc/verifytoken.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + VerifyToken + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

VerifyToken

+

Same as grapgql_jwt implementation, with standard output.

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/workinggroupinput.doc.html b/backend/docs/graphdoc/workinggroupinput.doc.html new file mode 100644 index 00000000..5708f9dd --- /dev/null +++ b/backend/docs/graphdoc/workinggroupinput.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + WorkingGroupInput + + + + +
+
+
+ +
+
+ +
+

INPUT_OBJECT

+

WorkingGroupInput

+

GraphQL Input type for setting working group

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+
  • input WorkingGroupInput {
  • # Name of the working group
  • name: String
  • # Name of institution of working group
  • institution: String!
  • # City of working group
  • city: String!
  • # Country of working group
  • country: String!
  • }
+
+
+
+
+

+ + link + + Require by +

+ +
+
+ +
+ + + + diff --git a/backend/docs/graphdoc/workinggrouptype.doc.html b/backend/docs/graphdoc/workinggrouptype.doc.html new file mode 100644 index 00000000..875c7f16 --- /dev/null +++ b/backend/docs/graphdoc/workinggrouptype.doc.html @@ -0,0 +1,482 @@ + + + + + + + + + + WorkingGroupType + + + + +
+
+
+ +
+
+ +
+

OBJECT

+

WorkingGroupType

+

GraphQL Working Group Type

+
+
+
+
+
+

+ + link + + GraphQL Schema definition +

+ +
+
+
+ +
+ +
+ + + + diff --git a/backend/docs/graphql/data_mutations.md b/backend/docs/graphql/data_mutations.md new file mode 100644 index 00000000..9e505717 --- /dev/null +++ b/backend/docs/graphql/data_mutations.md @@ -0,0 +1,164 @@ +# GraphQL: Adding co2 data + + +After server is running open `localhost:8000/graphql` in the browser. + + + +## Electricity + +### Front-End Form: + +Electricity data should be entered for each month. + +| Name| Input Type | Options / Comment | Tooltip | +|-----|------------------|----------------|---------------------| +| Date | Dropdown Fields | 1 box for Year and 1 box for Month | +| Building | Text input field | | Please enter the name of your institute building (optional). | +| Group share | Float input field | min:0, max: 1 | Your research group's share of the total electricity consumption of your building. Please give your best estimate and enter it as a decimal number, e.g., if your group consumes about 50% of the electricity of the building, enter 0.5. Only applicable if your research group does not occupy the whole building. Default value: 1 | +| Consumption (kWh) | Float input field | | Electricity consumption of your research group (kWh) for the selected time period and energy source. | +| Energy source | Dropdown field | Options: German energy mix, Solar | Default: German energy mix | + + + +### Query: + +``` +mutation createElectricity { + createElectricity (input: { + timestamp: "2020-12-01" + consumption: 3000 + fuelType: "Solar" + building: "348" + groupShare: 1 + }) { + ok + electricity { + timestamp + consumption + building + fuelType + co2e + } + } +} +``` + + +## Heating + +### Front-End Form: + +| Name| Input Type | Options / Comment | Tooltip | +|-----|------------------|------------|-----------| +| Date | Dropdown Fields | 1 box for Year and 1 box for Month | +| Building | Text input field | | Please enter the name of your institute building (optional). | +| Group share | Float input field | min:0, max: 1 | Your research group's share of the total electricity consumption of your building. Please give your best estimate and enter it as a decimal number, e.g., if your group consumes about 50% of the electricity of the building, enter 0.5. Only applicable if your research group does not occupy the whole building. Default value: 1 | +| Consumption (kWh) | Float input field | | Heating consumption of your research group for the selected time period and energy source. | +| Unit | Dropdown field | Options: l, kg, kwh, m^3| Default unit: kWh. For some energy sources, other units are supported: oil: l, Liguid gas, Coal, Wood (pellets), Wood (woodchips): kg, gas: m^3 | +| Energy source | Dropdown field | Options: Coal, District Heating, Electricity, Gas, Heat pump (air), Heat pump (ground), Heat pump (water), Liquid gas, Oil, Solar, Wood (pellets), Wood (wood chips) | + + + +### Query: + +``` +mutation createHeating{ + createHeating (input: { + building: "348" + timestamp: "2022-10-01" + consumption: 3000 + unit: "l" + fuelType: "Oil" + groupShare: 1 + }) { + ok + heating { + timestamp + consumption + fuelType + co2e + } + } +} +``` + +## Business Trip + +### Front-End Form: + +| Name| Input Type | Options / Comment | Tooltip | +|-----|------------------|--------------------|-----------| +| Date | Date Field | with year, month, day | +| Transportation mode | Drop down | Options: Car, Train, Plane, Bus, Ferry | If one trip was done with different modes of transport (e.g. train and bus), please enter them as individual trips or select the dominant mode of transport. | +| Start | Text fields | 3 fields for address, city, country | Start of the trip (alternatively, distance can be provided) | +| Destination | Text fields | 3 fields for address, city, country | Destination of the trip (alternatively, distance can be provided) | +| Distance | Float field | | Distance travelled in km (alternatively, start and destination can be provided) | +| Size | Dropdown | Options: small, medium, large, average (only for car and bus) | Default: average | +| Fuel type | Dropdown | Options: diesel, gasoline, electricity, cng, hybrid, plug-in_hybrid, average (only for car, bus, and train) | Possible values: car: [diesel, gasoline, cng, electricity, hybrid, plug-in_hybrid, average] bus: [diesel] train: [diesel, electricity, average]. Defaults: average for car and train, diesel for bus | +| Occupancy [%] | Dropdown | options: [20, 50, 80, 100] (only for bus) | Occupancy of the bus in %. Occupancy of 50 % means half of the bus seats were occupied. Default: 50 | +| Seating class | Dropdown | Options: "average", "Economy class", "Premium economy class", "Business class", "First class", "Foot passenger", "Car passenger" (only for plane and ferry) | Default: average | +| Passengers | Int Field | 1 - 9 (only for car) | Number of passengers in the car (including the person answering the questionnaire). Default: 1 | +| Round trip | Check box | | Please check the box if the trip was a roundtrip. | + + +### Query: + + +``` +mutation createBusinesstrip { + createBusinesstrip (input: { + timestamp: "2020-01-01" + transportationMode: "Car" + distance: 200 + size: "Medium" + fuelType: "Gasoline" + passengers: 1 + roundtrip: false + }) { + ok + businesstrip { + distance + } + } +} +``` + +## Commuting + +### Front-End Form: + +| Name| Input Type | Options / Comment | Tooltip | +|-----|------------------|--------------------|------------| +| Transportation mode | Drop down | Options: Car, Bus, Train, Bicycle, Pedelec, Motorbike, Tram | If your commute contains multiple transportation modes, please fill in the form separately for each transportation mode. | +| Distance [km] | Float | Min: 0 | Distance you commuted per week in the selected time period with the selected transportation mode. Please enter your usual commuting behaviour. If you often use a different mode of transport if there is bad weather or in the cold season, please account for this by estimating the mean distance for each transportation mode over the entire year. You may also fill in this form separately for each month or once for the summer months and for the winter months (e.g., April-October and November-March). | +| From | Dropdown | 1 box for Year and 1 box for Month | +| To | Dropdown | 1 box for Year and 1 box for Month | +| Fuel type | Float input field | Options: diesel, gasoline, electricity, cng, hydrogen, average (only for car, bus, and train) | Possible values: car: [diesel, gasoline, cng, electricity, average] bus: [diesel] train: [diesel, electricity, average]. Defaults: average for car and train, diesel for bus | +| Size | Dropdown | Options: small, medium, large, average (only for car and bus) | Default: average | +| Passengers | Integer | 1 - 9 (only for car) | Number of passengers in the car (including the person answering the questionnaire). Default: 1 | +| Occupancy [%] | Dropdown | options: [20, 50, 80, 100] (only for bus) | Occupancy of the bus in %. Occupancy of 50 % means half of the bus seats were occupied. Default: 50 | +| Annual work weeks| Integer | Max: 52 | Number of weeks you were working in the selected time period. Please account for all times you were not working such as paid leave or public holidays. | + + +### Query + +``` +mutation createCommuting { + createCommuting (input: { + transportationMode: "Car" + distance: 30 + fromTimestamp: "2017-01-01" + toTimestamp: "2017-06-01" + fuelType: "Gasoline" + size: "Medium" + passengers: 1 + workweeks: 40 + }) { + ok + } +} +``` + +### Resources +[Sanatan, M.: Building a GraphQL API with Django](https://stackabuse.com/building-a-graphql-api-with-django/) diff --git a/backend/docs/graphql/data_queries.md b/backend/docs/graphql/data_queries.md new file mode 100644 index 00000000..04ae805d --- /dev/null +++ b/backend/docs/graphql/data_queries.md @@ -0,0 +1,306 @@ +# GraphQL: Data requests + +CO2e emission data over time can be queried using the following endpoints, all of which require an **authenticated user** (i.e. valid token in header). + +- heatingAggregated +- electricityAggregated +- businesstripAggregated +- commutingAggregated +- [businesstrips](#Query-Businesstrip-entries-for-the-currently-logged-in-user) +- commutings +- heatings +- electricities + +The co2e emission can be returned as + +- absolute emissions (`co2e`) +- emissions per capita (`co2eCap`) + +per + +- month (`time_interval="month"`) - default +- year (`time_interval="year"`) + +for the levels + +- personal (`level="personal"`) +- group (`level="group"`) - default +- institution (`level="institution"`) + +A user can only query their own data or the data of their respective working group or institution. group ID, institution ID do not need to specified explicitly in the request, since this information is derived from the database. + +### Examples: + +#### Monthly absolute emissions of business trips of a user + +**Request:** (Python example) + +``` python +query = """ + query ($level: String!) { + businesstripAggregated (level: $level) { + date + co2e + co2eCap + } +} +""" +variables = {"level": "personal"} +headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user_token}", +} +response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers +) +``` + + +**Response:** + +``` json +{ + "data": { + "businesstripAggregated": [ + { + "co2e": 3229, + "date": "2019-01-01" + }, + { + "co2e": 3608, + "date": "2019-02-01" + }, + { + "co2e": 3111, + "date": "2019-03-01" + }, + ] + } +} +``` + +#### Monthly absolute emissions of heating consumption of a working group + +**Request:** + +``` json +query ($level: String!) { + heatingAggregated (level: $level) { + date + co2e + co2eCap + } +} +``` + +**Response:** + +``` +{ + "data": { + "heatingAggregated": [ + { + "co2e": 188.04196799999846, + "date": "2019-01-01" + }, + { + "co2e": 186.1296767999985, + "date": "2019-02-01" + }, + { + "co2e": 221.6664215999982, + "date": "2019-03-01" + }, + ] + } +} +``` + +#### Monthly absolute and per capita emissions of electricity consumption of an institution + +**Request:** + +``` json +query ($level: String!) { + electricityAggregated (level: $level) { + date + co2e + co2eCap + } +} +``` + +**Response:** + +``` +{ + "data": { + "electricityAggregate": [ + { + "co2e": 3521.1789287999713, + "co2eCap": 234.7452619199981, + "date": "2019-01-01" + }, + { + "co2e": 4669.278026399962, + "co2eCap": 311.28520175999745, + "date": "2019-02-01" + } + ] + } +} +``` + + +#### Monthly absolute and per capita emissions from commuting for a working group + +**Request:** + +``` json +query ($level: String!) { + commutingAggregated (level: $level) { + date + co2e + co2eCap + } +} +``` + +**Response:** + +``` +{ + "data": { + "commutingAggregated": [ + { + "co2eCap": 1.5799993939731014, + "co2e": 23.69999090959652, + "date": "2017-01-01" + }, + { + "co2eCap": 1.5799993939731014, + "co2e": 23.69999090959652, + "date": "2017-02-01" + } + ] + } +} +``` + +##### Query multiple types of emissions + +**Query:** + +``` +query { + commutingAggregated (level:"group", timeInterval:"month") { + co2e + co2eCap + date + } + heatingAggregated (level:"group", timeInterval:"month") { + co2e + co2eCap + date + } + electricityAggregated (level:"group", timeInterval:"month") { + co2e + co2eCap + date + } + businesstripAggregated (level:"group", timeInterval:"month") { + co2e + co2eCap + date + } +} +``` + +**Response:** + + +``` + "data": { + "businesstripAggregated": [ + { + "co2e": 3229, + "date": "2019-01-01" + }, + { + "co2e": 3608, + "date": "2019-02-01" + }, + { + "co2e": 3111, + "date": "2019-03-01" + }, + ], + "heatingAggregated": [ + ... + ], + "electricityAggregated": [ + ... + ], + "commutingAggregated": [ + ... + ] + } +} +``` + + +##### Query Businesstrip entries for the currently logged in user + +**Query:** + +``` +query { + businesstrips { + distance + timestamp + co2e + } +} +``` + +**Response:** + +``` +{ + "data": { + "businesstrips": [ + { + "distance": 9122, + "timestamp": "2019-01-15", + "co2e": 301 + }, + { + "distance": 5784, + "timestamp": "2019-02-14", + "co2e": 578 + } + ]} +} +``` + +##### Query dropdown options for `fuel_type` and `unit` attribute + +For example + +**Query** + +``` +{ __type(name: "HeatingFuelType") { + enumValues { + name + description + } +} +} +``` + +**Response** + +``` +{'data': {'__type': {'enumValues': [{'name': 'GERMAN_ENERGY_MIX', 'description': 'German energy mix'}, {'name': 'SOLAR', 'description': 'Solar'}]}}} +``` diff --git a/backend/docs/graphql/endpoint_overview.md b/backend/docs/graphql/endpoint_overview.md new file mode 100644 index 00000000..e058b4ad --- /dev/null +++ b/backend/docs/graphql/endpoint_overview.md @@ -0,0 +1,76 @@ +# Endpoints + +Authentication levels: + +- **public**: No authentication needed +- **log_in_user**: User must be authenticated by sending valid JWT Token in header. +- **log_in_representative**: User must be authenticated and be the representative of their group. + + +## User management + These mutations were implemneted using the [graphql-auth](https://django-graphql-auth.readthedocs.io/en/latest/quickstart/) packages. So they should be working. + +|Name | Status| Tested | Authentication level| +|:----|-------|-------|------| +| register | :white_check_mark: | :white_check_mark: | public | +| verify_account |:white_check_mark:| :white_check_mark:| public | +| resend_activation_email | :white_check_mark: || public | +| send_password_reset_email | :white_check_mark: || public | +| password_reset | :white_check_mark: || public | +| password_set | :white_check_mark: || log_in_user | +| password_change | :white_check_mark:|| log_in_user | +| update_account | :white_check_mark: | :white_check_mark: | log_in_user | +| archive_account | :white_check_mark: || log_in_user | +| delete_account | :white_check_mark: | :white_check_mark: | log_in_user | +| send_secondary_email_activation | :white_check_mark: || log_in_user | +| verify_secondary_email | :white_check_mark: || log_in_user | +| swap_emails | :white_check_mark: || log_in_user | +| remove_secondary_email | :white_check_mark: || log_in_user | +| token_auth (Log in) | :white_check_mark: || public | +| verify_token | :white_check_mark: || public | +| refresh_token | :white_check_mark: || public | +| revoke_token | :white_check_mark: || public | + + +## Working group management + +|Name | Status| Tested | Authentication level| +|:----|-------|-------|------| +| create_working_group | :white_check_mark: | :white_check_mark: | log_in_user | +| update_working_group | || log_in_representative | +| set_working_group | :white_check_mark: | :white_check_mark: | log_in_user | +| institutions | :white_check_mark: | :white_check_mark:| log_in_user | +| working_groups | :white_check_mark: | :white_check_mark:| log_in_user | +| researchfields | :white_check_mark: | :white_check_mark:| log_in_user | + +## Emissions + +|Name | Status| Tested | Authentication level| +|:----|-------|-------|------| +| create_heating | :white_check_mark: | :white_check_mark: |log_in_representative | +| create_electricity | :white_check_mark: | :white_check_mark: |log_in_representative | +| create_businesstrip | :white_check_mark: | :white_check_mark: | log_in_user | +| create_commuting | :white_check_mark: | :white_check_mark: | log_in_user | +| resolve_businesstrips| :white_check_mark: || log_in_user | +| resolve_electricities| :white_check_mark: || log_in_user | +| resolve_heatings| :white_check_mark: || log_in_user | +| resolve_commutings| :white_check_mark: || log_in_user | +| resolve_heating_aggregated| :white_check_mark: | :white_check_mark: | log_in_user | +| resolve_electricity_aggregated|:white_check_mark: | :white_check_mark: | log_in_user | +| resolve_businesstrip_aggregated|:white_check_mark: | :white_check_mark:| log_in_user| +| resolve_commuting_aggregated| :white_check_mark:| :white_check_mark:| log_in_user | +| total_emissions | :white_check_mark:| :white_check_mark:| public | + + +## Optional additional endpoints + +|Name | Status| Tested | Authentication level| +|:----|-------|-------|------| +| delete_heating | | |log_in_representative | +| delete_electricity | ||log_in_representative | +| delete_businesstrip | || log_in_user | +| delete_commuting | || log_in_user | +| update_heating | | |log_in_representative | +| update_electricity | ||log_in_representative | +| update_businesstrip | || log_in_user | +| update_commuting | || log_in_user | diff --git a/backend/docs/graphql/errors.md b/backend/docs/graphql/errors.md new file mode 100644 index 00000000..510c790c --- /dev/null +++ b/backend/docs/graphql/errors.md @@ -0,0 +1,48 @@ +# Errors + +## Debugging backend containers + +Before rebuilding all backend docker containers, do the following: +1. Delete all files except for the *__init__.py* in the folder *./WePledge/backend/src/emissions/migrations*. +2. Delete all backend containers (wepledge_pgadmin_1, wepledge_backend_1 and db) +3. Run `docker volume prune` to delete the database. +4. Run `docker compose up´. + + +## `Module not found` in backend container + +The backend container won't build correctly, because `Module not found django_extensions`. + +**Solution:** Delete all containerst and images. Then run `docker volume prune` to delete all data associated with them. + + +## Send Registration email failed + +When registering a new user, sending the activation email fails. + +**Solution:** Add the `EMAIL_FROM` variable to `settings.py` (see [Django-Graphql_Auth Docs](https://django-graphql-auth.readthedocs.io/en/latest/settings/)) + +``` +GRAPHQL_AUTH = { + 'LOGIN_ALLOWED_FIELDS': ['email'], + 'SEND_ACTIVATION_EMAIL': True, + 'EMAIL_FROM': 'no-reply@pledge4future.org', +} +``` + +## `Are you trying to mount a directory onto a file` + +``` +Error response from daemon: OCI runtime create failed: container_linux.go:380: starting container process caused: process_linux.go:545: container init cau +sed: rootfs_linux.go:76: mounting "/run/desktop/mnt/host/c/Users/ninak/Documents/pledge4future/git1/WePledge/.env" to rootfs at "/home/python/app/src/.env +" caused: mount through procfd: not a directory: unknown: Are you trying to mount a directory onto a file (or vice-versa)? Check if the specified host pat +h exists and is the expected type +``` + + +**Solution:** + +1. Make sure the .env file in `./WePledge` exists. If not create it. +2. Delete all folders which were created by docker called `.env` in `./WePledge` and `./WePledge/backend/src`. +3. Delete all containers, images and volumes and run `docker compose up --build` again. + diff --git a/backend/docs/graphql_user_requests.md b/backend/docs/graphql/user_management.md similarity index 70% rename from backend/docs/graphql_user_requests.md rename to backend/docs/graphql/user_management.md index 8a6ee1e0..bbf76faa 100644 --- a/backend/docs/graphql_user_requests.md +++ b/backend/docs/graphql/user_management.md @@ -1,22 +1,31 @@ -# GraphQL: User management +# GraphQL: Authentication + +The API including requests and responses is well documented [in the Django-GraphQL-Auth documentation](https://django-graphql-auth.readthedocs.io/en/latest/api). There is also a [video tutorial](https://www.youtube.com/watch?v=pyV2_F9wlk8&t=494s) along with code on [GitHub](https://github.com/veryacademy/YT-GraphQL-User-Authentication-GraphQL-Auth) + +### authentication + +Some request require authentication by sending a valid token in the header with the request. + +``` +header = {"Content-Type": "application/json", "Authorization": f"JWT {TOKEN}"} +``` + +For testing purposes, requests without authentication can be sent through `localhost:8000/graphql`. Requests with authentication require a valid token in the header, so they need to be sent through external software like [Postman](https://www.postman.com/) or scripts e.g. [Python API tests](../src/emissions/tests.py). + -The API including requests and possible responses is well documented [here](https://django-graphql-auth.readthedocs.io/en/latest/api). -The requests (except for the updateUser) can be sent through GraphiQL on `localhost:8000/graphql` or using [Postman](https://www.postman.com/) to `localhost:8000/graphql/`. If the requests reuqired sending a token in the header, you need to use postman. -As a tutorial, everything is explained in this [video tutorial](https://www.youtube.com/watch?v=pyV2_F9wlk8&t=494s) along with code on [GitHub](https://github.com/veryacademy/YT-GraphQL-User-Authentication-GraphQL-Auth) ## Register a new user account -### 1. Register new user +### 1. Register new user -Required info from user: +Required info from user: * Email -* username * Password -* Repeat password - +* Repeat password + #### request @@ -24,14 +33,13 @@ Required info from user: mutation { register ( email: "test@pledge4future.org", - username: "lisalou", password1: "lisa445566!", password2: "lisa445566!" ) { success errors token - refreshToken + refreshToken } } ``` @@ -51,19 +59,19 @@ mutation { } ``` -### 2. Verify email +### 2. Verify email + +After the user has been registered an activation email is sent to the email given by the user. + +If sending the email fails, set `EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'` in `./backend/src/wepledge/settings.py` so the email text will be printed in the command line. -After the user has been registered an activation email is sent to the email given by the user. -If sending the email fails, set `EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'` in `./backend/src/wepledge/settings.py` so the email text will be printed in the command line. - -

localhost:8000

Hello lisalou!

Please activate your account on the link:

http://localhost:8000/activate/eyJlbWFpbCI6Imxpc2Fsb3VAdW5pLWhkLmRlIiwiYWN0aW9uIjoiYWN0aXZhdGlvbiJ9:1mCmEp:eBGetW65MtzO5f9LJAIhFKHjhTcwEeS1Ys2sxUgMWIQ

-The token in the activation url is needed to verify the account. +The token in the activation url is needed to verify the account. #### request @@ -93,12 +101,12 @@ mutation { ### 3. Log in User -Required info from user: +Required info from user: -* email +* email * password -#### request +#### Request ``` mutation { @@ -111,7 +119,6 @@ mutation { token refreshToken user { - username firstName email isRepresentative @@ -131,7 +138,6 @@ mutation { "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Imxpc2Fsb3VAdW5pLWhkLmRlIiwiZXhwIjoxNjI4NDQzNjc2LCJvcmlnSWF0IjoxNjI4NDQzMzc2fQ.SyQFNdccgxPnmMPtTmTKcOsNrhSlcdPVKOkyc-jjcm0", "refreshToken": "6a548eb3aacc5886dd366d9e419ee4aad08aa9fc", "user": { - "username": "lisalou", "firstName": "", "email": "lisalou@uni-hd.de", "isRepresentative": false @@ -141,14 +147,24 @@ mutation { } ``` -### 4. Update account +### 4. Update account + +User account needs to be verified firist. -User needs to be verified to update account data. Send requests using Postman so that token can be passed in header. +**Requres authentication by sending token in header** -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#updateaccount) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#updateaccount) for more details. #### Graphql Query +**Header** + +``` +header = {"Content-Type": "application/json", "Authorization": f"JWT {TOKEN}"} +``` + +**Request** + ``` mutation { updateAccount ( @@ -160,7 +176,7 @@ mutation { } ``` -#### response +#### Response ``` { @@ -176,10 +192,20 @@ mutation { ### 5. Password reset -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordreset) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordreset) for more details. +**Requres authentication by sending token in header** +   #### Graphql Query +**Header** + +``` +header = {"Content-Type": "application/json", "Authorization": f"JWT {TOKEN}"} +``` + +**Request** + ``` mutation { passwordReset( @@ -193,7 +219,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -206,9 +232,9 @@ mutation { } ``` -### 6. Resend activation email +### 6. Resend activation email -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#resendactivationemail) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#resendactivationemail) for more details. #### Graphql Query @@ -224,7 +250,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -237,11 +263,12 @@ mutation { } ``` -### 7. Send password reset email +### 7. Send password reset email Send password reset email. For non verified users, send an activation email instead. Accepts both primary and secondary email. If there is no user with the requested email, a successful response is returned. -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#sendpasswordresetemail) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#sendpasswordresetemail) for more details. + #### Graphql Query @@ -256,7 +283,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -270,11 +297,11 @@ mutation { ``` -### 8. Send password reset email +### 8. Send password reset email Change account password when user knows the old password. A new token and refresh token are sent. User must be verified. -See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordchange) for more details. +See [documentation](https://django-graphql-auth.readthedocs.io/en/latest/api/#passwordchange) for more details. #### Graphql Query @@ -293,7 +320,7 @@ mutation { } ``` -#### response +#### response ``` { @@ -313,32 +340,42 @@ mutation { ## Queries -#### Get current user with working group and institution info +#### Get current user info including info on working group, institution or research field (remove the attribtues which are not needed) ``` query { me { - username + email + firstName + lastName + isRepresentative + verified workingGroup { + id name - groupId + nEmployees institution { - name - instId + name + city + state + country + } + field { + field + subfield } } } } ``` -#### Get all users +#### Get all users ``` query { users { edges { node { - username email } } diff --git a/backend/docs/graphql/working_group_management.md b/backend/docs/graphql/working_group_management.md new file mode 100644 index 00000000..70109fa5 --- /dev/null +++ b/backend/docs/graphql/working_group_management.md @@ -0,0 +1,112 @@ +## API: Working group management + +After a user has registered they can/should join a working group, for which there are three scenarios: + +#### 1. The user's working group already exists in the app. + +If the user's working group already exist, they can choose it by selecting the country, city, institution (e.g. Heidelberg University) and the working group's name from several dropdown menus. Valid countries, cities and institutions can be queried using the [institutions endpoint](#list-institutions), existing working groups using the [workinggroups endpoint](#list-working-groups). The selected working can be sent to backend using the [setworkingroup endpint](#set-working-group). + +#### 2. The user's working group does not exist yet in the app + +If the user's working group does not exists yet, they can create it by providing + +- the working group's name +- the institution (incl. country and city) it belongs to (from predefined selection) +- research field (from predefined selection) +- number of employees. + +## API requests + +### List working groups + +``` +query { + workinggroups { + id + name + field { + field + subfield + } + } +} +``` + +### List institutions + +``` +query { + institutions { + id + name + city + country + } +} +``` + +### List research fields + +``` +query { + researchfields { + field + subfield + } +} +``` + + +### Set working group + +``` +mutation ($name: String!, $institution: String!, $city: String!, $country: String!){ + setWorkingGroup (input: { + name: $name + institution: $institution + city: $city + country: $country + } + ) { + ok + user { + email + workingGroup { + name + } + } + } +} +``` + +## Create Working group + +``` +query = """ + mutation { + createWorkingGroup (input: { + name: "Hydrology" + institution: "Heidelberg University" + city: "Heidelberg" + country: "Germany" + field: "Natural Sciences" + subfield: "Earth and related environmental sciences" + nEmployees: 5 + }) { + ok + workinggroup { + name + representative { + email + } + } + } + } +""" +headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user_representative_token}", +} +response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + +``` diff --git a/backend/docs/graphql_add_data_queries.md b/backend/docs/graphql_add_data_queries.md deleted file mode 100644 index 31a40547..00000000 --- a/backend/docs/graphql_add_data_queries.md +++ /dev/null @@ -1,163 +0,0 @@ -# GraphQL: Adding co2 data - - -After server is running open `localhost:8000/graphql` in the browser. - - - -## Electricity - -### Front-End Form: - -Electricity data should be entered for each month. - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Dropdown Fields | 1 box for Year and 1 box for Month | -| Building | Text input field | | -| Group share | Float input field | min:0, max: 1 | -| Consumption (kWh) | Float input field | |  -| Energy source | Dropdown field | Options: Coal,District Heating,Electricity,Gas, Heat pump (air), Heat pump (ground), Heat pump (water), Liquid gas, Oil, Solar, Wood (pellets), Wood (wood chips) | - - - -### Query: - -``` -mutation createElectricity { - createElectricity (input: { - group_id: "" - timestamp: "2020-10-01" - consumption: 3000 - fuelType: "solar" - building: "348" - groupShare: 1 - }) { - ok - electricity { - timestamp - consumption - building - fuelType - co2e - } - } -} -``` - - -## Heating - -### Front-End Form: - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Dropdown Fields | 1 box for Year and 1 box for Month | -| Building | Text input field | | -| Consumption (kWh) | Float input field | |  -| Unit | Dropdown field | Options: l, kg, kwh, m^3|  -| Energy source | Dropdown field | Options: German energy mix, Solar | -| Group share | Float input field | min:0, max: 1 | - - -### Query: - -``` -mutation createHeating{ - createHeating (input: { - group_id: "" - building: "348" - timestamp: "2022-10-01" - consumption: 3000 - unit: "l" - fuelType: "oil" - groupShare: 1 - }) { - ok - heating { - timestamp - consumptionKwh - fuelType - co2e - } - } -} -``` - -## Business Trip - -### Front-End Form: - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Date Field | with year, month, day | -| Transportation mode | Drop down | Options: Car, Train, Plane, Bus | -| Start | Text fields | 3 fields for address, city, country |  -| Destination | Text fields | 3 fields for address, city, country |  -| Distance | Float field | | -| Size | Dropdown | Options: small, medium, large, average (only for car and bus) | -| Fuel type | Dropdown | Options: gasoline, diesel (only for car and bus) | -| Occupancy | Int Field | 0 - 100 (only for bus) | -| Seating class | Dropdown | Options: "average", "Economy class", "Premium economy class", "Business class", "First class" (only for plane) | -| Passengers | Int Field | 1 - 9 (only for car) | -| Round trip | Check box | | - - -### Query: - - -``` -mutation createBusinesstrip { - createBusinesstrip (input: { - username: "KarenAnderson" - groupId: "573b7bec-e9fe-4505-bb41-2bf9a2769a80" - timestamp: "2020-01-01" - transportationMode: "car" - distance: 200 - size: "medium" - fuelType: "gasoline" - passengers: 1 - roundtrip: false - }) { - ok - } -} -``` - -## Commuting - -### Front-End Form: - -| Name| Input Type | Options / Comment | -|-----|------------------|--------------------| -| Date | Dropdown Fields | 1 box for Year and 1 box for Month | -| Building | Text input field | | -| Consumption (kWh) | Float input field | |  -| Unit | Dropdown | Options: l, kg, kwh, m^3|  -| Energy source | Dropdown field | German energy mix, Solar | -| Group share | Float input field | min:0, max: 1 | - - - -### Query - -``` -mutation createCommuting { - createCommuting (input: { - username: "KlausMayer" - distance: 30 - transportationMode: "car" - fuelType: "gasoline" - size: "medium" - fromTimestamp: "2017-01-01" - toTimestamp: "2017-06-01" - workweeks: 40 - passengers: 1 - }) { - ok - } -} -``` - -### Resources -[Sanatan, M.: Building a GraphQL API with Django](https://stackabuse.com/building-a-graphql-api-with-django/) \ No newline at end of file diff --git a/backend/docs/graphql_data_requests.md b/backend/docs/graphql_data_requests.md deleted file mode 100644 index 772d35ac..00000000 --- a/backend/docs/graphql_data_requests.md +++ /dev/null @@ -1,201 +0,0 @@ -# GraphQL: Data requests - -## Queries - -There are three types of queries to request monthly (default) or annual co2e data: - -- **heatingAggregated** -- **electricityAggregated** -- **businesstripAggregated** -- **commutingAggregated** -- **allAggregated** (not implemented yet) - -The **aggregation level** can be specified using the arguments - -- **username:** co2e on user level (only for businesstrips) -- **groupId:** co2e on working group level -- **instId:** co2e on institution level - -The co2e emission can be returned as - -- absolute emissions (`co2e`) -- emissions per capita (`co2eCap`) - -per - -- month (`time_interval="month"`) -- year (`time_interval="year"`) - -### Examples: - -##### All aggregated (not implemented yet) - -``` - "data": { - "businesstripAggregated": [ - { - "co2e": 3229, - "date": "2019-01-01" - }, - { - "co2e": 3608, - "date": "2019-02-01" - }, - { - "co2e": 3111, - "date": "2019-03-01" - }, - ], - "heatingAggregated": [ - ... - ] - } -} -``` - -#### Monthly absolute emissions of business trips of a user -**Request:** - -``` json -query { - businessTripAggregated (username:"KimKlaus", time_interval="month") { - co2e - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "businesstripAggregated": [ - { - "co2e": 3229, - "date": "2019-01-01" - }, - { - "co2e": 3608, - "date": "2019-02-01" - }, - { - "co2e": 3111, - "date": "2019-03-01" - }, - ] - } -} -``` - -#### Monthly absolute emissions of heating consumption of a working group - -[How to get all group ids](./graphql_user_requests.md). - -**Request:** - -``` json -query { - heatingAggregated (groupId:"f6c2965c-539e-456c-8e99-41cea9be4168") { - co2e - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "heatingAggregated": [ - { - "co2e": 188.04196799999846, - "date": "2019-01-01" - }, - { - "co2e": 186.1296767999985, - "date": "2019-02-01" - }, - { - "co2e": 221.6664215999982, - "date": "2019-03-01" - }, - ] - } -} -``` - -#### Monthly absolute and per capita emissions of electricity consumption of an institution - -**Request:** - -``` json -query { - electricityAggregated (groupId:"c7876b21-6166-443b-97e5-f7c5413de520", - timeInterval:"month") { - co2e - co2eCap - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "electricityAggregate": [ - { - "co2e": 3521.1789287999713, - "co2eCap": 234.7452619199981, - "date": "2019-01-01" - }, - { - "co2e": 4669.278026399962, - "co2eCap": 311.28520175999745, - "date": "2019-02-01" - } - ] - } -} -``` - - -#### Monthly absolute and per capita emissions from commuting for a working group - -**Request:** - -``` -query { - commutingAggregated (groupId:"e0ee4c7f-f266-47e5-877f-15dd396d3a57", - timeInterval:"year") { - co2eCap - co2e - date - } -} -``` - -**Response:** - -``` -{ - "data": { - "commutingAggregated": [ - { - "co2eCap": 1.5799993939731014, - "co2e": 23.69999090959652, - "date": "2017-01-01" - }, - { - "co2eCap": 1.5799993939731014, - "co2e": 23.69999090959652, - "date": "2017-02-01" - } - ] - } -} -``` - diff --git a/backend/docs/graphql_errors.md b/backend/docs/graphql_errors.md deleted file mode 100644 index aeba5270..00000000 --- a/backend/docs/graphql_errors.md +++ /dev/null @@ -1,22 +0,0 @@ -# Errors - -### `Module not found` in backend container - -The backend container won't build correctly, because `Module not found django_extensions`. - -**Solution:** Delete all containerst and images. Then run `docker volume prune` to delete all data associated with them. - - -### Send Registration email failed - -When registering a new user, sending the activation email fails. - -**Solution:** Add the `EMAIL_FROM` variable to `settings.py` (see [Django-Graphql_Auth Docs](https://django-graphql-auth.readthedocs.io/en/latest/settings/)) - -``` -GRAPHQL_AUTH = { - 'LOGIN_ALLOWED_FIELDS': ['email', 'username'], - 'SEND_ACTIVATION_EMAIL': True, - 'EMAIL_FROM': 'no-reply@pledge4future.org', -} -``` diff --git a/backend/docs/img/database_structure.png b/backend/docs/img/database_structure.png new file mode 100644 index 00000000..da4ec93b Binary files /dev/null and b/backend/docs/img/database_structure.png differ diff --git a/backend/docs/postman/Pledge4Future.postman_collection.json b/backend/docs/postman/Pledge4Future.postman_collection.json deleted file mode 100644 index 5da36719..00000000 --- a/backend/docs/postman/Pledge4Future.postman_collection.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "info": { - "_postman_id": "46aea853-3195-420f-b922-25bc44c94b6e", - "name": "Pledge4Future", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Register", - "request": { - "method": "POST", - "header": [ - { - "key": "token", - "value": "", - "type": "text" - } - ], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\tregister (\n email: \"test@pledge4future.org\",\n username: \"lisalou\",\n password1: \"lisa445566!\",\n password2: \"lisa445566!\"\n ) {\n\t success\n errors\n token\n refreshToken \n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "Verify Account", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\tverifyAccount (\n token: \"eyJlbWFpbCI6InRlc3RAcGxlZGdlNGZ1dHVyZS5vcmciLCJhY3Rpb24iOiJhY3RpdmF0aW9uIn0:1mIEsJ:ARBmprFuNQ7jIpnzsW0tGUz36Vr6wQLukkRiMJMAU6c\"\n ) {\n\t\tsuccess\n errors\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "Login", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\ttokenAuth (\n email: \"test@pledge4future.org\"\n password: \"lisa445566!\"\n ) {\n\t success\n errors\n token\n refreshToken\n user {\n username\n firstName\n email\n isRepresentative\n }\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "RefreshToken", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n tokenAuth(\n # username or email\n email: \"test@pledge4future.org\"\n password: \"lisa445566!\"\n ) {\n success,\n errors,\n token,\n refreshToken,\n unarchiving,\n user {\n id,\n username\n }\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - }, - { - "name": "UpdateUser", - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRlc3RAcGxlZGdlNGZ1dHVyZS5vcmciLCJleHAiOjE2Mjk3NDQzNzIsIm9yaWdJYXQiOjE2Mjk3NDQwNzJ9.EuostrVc_x1m2C8Aau3xleI1y7P72KMWi0wssjt3wM4", - "type": "text" - } - ], - "body": { - "mode": "graphql", - "graphql": { - "query": "mutation {\n\tupdateAccount (\n firstName: \"Louise\"\n ) {\n\tsuccess\n errors\n }\n}", - "variables": "" - } - }, - "url": { - "raw": "localhost:8000/graphql/", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "graphql", - "" - ] - } - }, - "response": [] - } - ] -} \ No newline at end of file diff --git a/backend/assets/requirements.txt b/backend/requirements.txt similarity index 75% rename from backend/assets/requirements.txt rename to backend/requirements.txt index 6a850582..db11ae1c 100644 --- a/backend/assets/requirements.txt +++ b/backend/requirements.txt @@ -5,7 +5,6 @@ chardet==4.0.0 Django==3.1.7 django-cors-headers==3.7.0 django-extensions==3.1.1 -django-cors-headers==3.7.0 django-filter==2.4.0 django-graphql-auth==0.3.16 django-graphql-jwt==0.3.0 @@ -16,14 +15,17 @@ graphene-django==2.15.0 graphql-core==2.3.2 graphql-relay==2.0.1 idna==2.10 -numpy==1.20.2 +numpy==1.23.0 openrouteservice==2.3.3 -pandas==1.2.3 +pandas==1.4.0 promise==2.3 psycopg2-binary==2.8.6 +pydot==1.4.2 PyJWT==1.7.1 +pyparsing==3.0.9 python-dateutil==2.8.1 python-dotenv==0.17.1 +python-Levenshtein==0.12.2 pytz==2021.1 requests==2.25.1 requests-toolbelt==0.9.1 @@ -32,4 +34,11 @@ singledispatch==3.6.1 six==1.15.0 sqlparse==0.4.1 text-unidecode==1.3 -urllib3==1.26.4 \ No newline at end of file +thefuzz==0.19.0 +urllib3==1.26.4 +whitenoise==6.2.0 +pydot==1.4.2 +gunicorn +iso3166 +pydantic==1.9.0 +python-Levenshtein diff --git a/backend/src/co2calculator b/backend/src/co2calculator index 1377c52b..761bb5c6 160000 --- a/backend/src/co2calculator +++ b/backend/src/co2calculator @@ -1 +1 @@ -Subproject commit 1377c52bf3d1f8a603aaa4a08603af8cc1f5b0b3 +Subproject commit 761bb5c61ac88470612e0dc30b3896d89d2c6837 diff --git a/backend/src/emissions/__init__.py b/backend/src/emissions/__init__.py index e69de29b..54510dbd 100644 --- a/backend/src/emissions/__init__.py +++ b/backend/src/emissions/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/admin.py b/backend/src/emissions/admin.py index b1cdccd1..86bff333 100644 --- a/backend/src/emissions/admin.py +++ b/backend/src/emissions/admin.py @@ -1,10 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Admin settings""" + + from django.contrib import admin -from emissions.models import User, WorkingGroup, BusinessTrip, Heating, Electricity, Institution, Commuting, \ - CommutingGroup, BusinessTripGroup from django.apps import apps +from emissions.models import ( + CustomUser, + WorkingGroup, + Institution, + Heating, + Electricity, + Commuting, + CommutingGroup, + BusinessTrip, + BusinessTripGroup, + ResearchField, + WorkingGroupJoinRequest +) + +# Admin Models: Configure how information is displayed on Django Admin page + +class CustomUserAdmin(admin.ModelAdmin): + """Configures how CustomUser info is displayed""" + readonly_fields = ('is_representative', 'username', ) + -# Register your models here. -admin.site.register(User) +# Register your models here +admin.site.register(CustomUser, CustomUserAdmin) admin.site.register(WorkingGroup) admin.site.register(Institution) admin.site.register(Heating) @@ -13,8 +36,11 @@ admin.site.register(CommutingGroup) admin.site.register(BusinessTrip) admin.site.register(BusinessTripGroup) +admin.site.register(ResearchField) +admin.site.register(WorkingGroupJoinRequest) -app = apps.get_app_config('graphql_auth') +# GraphQL +app = apps.get_app_config("graphql_auth") -for model_name, model in app.models.items(): +for _, model in app.models.items(): admin.site.register(model) diff --git a/backend/src/emissions/apps.py b/backend/src/emissions/apps.py index 379f1db6..66b0f560 100644 --- a/backend/src/emissions/apps.py +++ b/backend/src/emissions/apps.py @@ -1,5 +1,14 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""apps""" + from django.apps import AppConfig class EmissionsConfig(AppConfig): - name = 'emissions' + """Config""" + + name = "emissions" + + def ready(self): + import emissions.signals diff --git a/backend/src/emissions/data/test_data.json b/backend/src/emissions/data/test_data.json new file mode 100644 index 00000000..4f78814f --- /dev/null +++ b/backend/src/emissions/data/test_data.json @@ -0,0 +1,106 @@ +{"users": { + "test_user1": { + "username": "test1", + "first_name": "test1", + "last_name": "user", + "email": "test1@pledge4future.org", + "password": "test_password" + }, + "test_user2": { + "username": "test2", + "first_name": "test2", + "last_name": "user", + "email": "test2@pledge4future.org", + "password": "test_password" + }, + "test_user3_representative": { + "username": "test3_rep", + "first_name": "test3_rep", + "last_name": "user", + "email": "test3@pledge4future.org", + "password": "test_password" + }, + "test_user4_representative": { + "username": "test4_rep", + "first_name": "test4_rep", + "last_name": "user", + "email": "test4@pledge4future.org", + "password": "test_password" + } +}, +"institutions": { + "institution1": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002", + "name": "Test University", + "city": "Heidelberg", + "country": "Germany" + } +}, +"working_groups": { + "working_group1": { + "id": "2f681772-9fcd-11ed-a8fc-0242ac120002", + "name": "testgroup1", + "institution": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002" + }, + "representative": "test3_rep", + "n_employees": 10, + "research_field": { + "id": 1 + }, + "is_public": "True" + }, + "working_group2": { + "id": "3aaa81a6-9fcd-11ed-a8fc-0242ac120002", + "name": "testgroup2", + "institution": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002" + }, + "representative": "test4_rep", + "n_employees": 10, + "research_field": { + "id": 2 + }, + "is_public": "False" + }, + "workinggroup_to_delete": { + "id": "4aaa81a6-9fcd-11ed-a8fc-0242ac120002", + "name": "workinggroup_to_delete", + "institution": { + "id": "59812cd8-9fcd-11ed-a8fc-0242ac120002" + }, + "representative": "test3_rep", + "n_employees": 10, + "research_field": { + "id": 2 + }, + "is_public": "False" + } +}, +"business_trips": { + "business_trip1": { + + } +}, +"heating": [ + { + "working_group_id": "2f681772-9fcd-11ed-a8fc-0242ac120002", + "consumption": 2000, + "unit": "l", + "fuel_type": "oil", + "building": "348", + "timestamp": "2022-01-01", + "group_share": 1 + } +], +"electricity": [ + { + "working_group_id": "2f681772-9fcd-11ed-a8fc-0242ac120002", + "consumption": 1000, + "fuel_type": "german_energy_mix", + "building": "348", + "timestamp": "2022-01-01", + "group_share": 1 + } +] +} \ No newline at end of file diff --git a/backend/src/emissions/decorators.py b/backend/src/emissions/decorators.py new file mode 100644 index 00000000..5f9ad01f --- /dev/null +++ b/backend/src/emissions/decorators.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Decorators to handle permissions""" + +from graphql import GraphQLError +from graphql_jwt.decorators import context +from functools import wraps +from typing import Callable + + +def representative_required(func: Callable): + """ + Decorator which checks whether a user is a group representative. If not raises GraphQLError. + :param func: + :type func: + :return: + :rtype: + """ + @wraps(func) + @context(func) + def wrapper_func(context, *args, **kwargs): + if context.user.is_representative: + return func(*args, **kwargs) + raise GraphQLError("Only group representatives have permission to perform this action.") + return wrapper_func diff --git a/backend/src/emissions/email_client.py b/backend/src/emissions/email_client.py new file mode 100644 index 00000000..65075077 --- /dev/null +++ b/backend/src/emissions/email_client.py @@ -0,0 +1,69 @@ +from pathlib import Path +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.core.mail import EmailMessage + + +class EmailClient: + """Handles sending emails""" + + def __init__(self, template_dir: str): + """ + Initialize + :param template_dir: Path to directory containing templates + """ + self.template_dir = Path(template_dir) / 'email' + + def get_template_email(self, name: str, values: dict = None): + """ + Get template for email text from template folder + :param name: Name of template + :param values: Values which should be replaced in template text + :return: + """ + if not values: + values = {} + template_file = self.template_dir / (name + '_email.html') + if not template_file.exists(): + raise FileNotFoundError(f'{template_file} does not exist.') + html_message = render_to_string(template_file, values) + text_content = strip_tags(html_message) # Strip the html tag. So people can see the pure text at least. + return text_content, html_message + + def get_template_subject(self, name: str, values: dict = None): + """ + Get template for email subject from template folder + :param name: Name of template + :param values: Values which should be replaced in template subject text + :return: + """ + if not values: + values = {} + template_file = self.template_dir / (name + '_subject.txt') + if not template_file.exists(): + raise FileNotFoundError(f'{template_file} does not exist.') + subject = render_to_string(template_file, values) + return subject + + def send_email(self, subject: str, html_message: str, from_email: str, to_email: str): + """ + Sends email + :param subject: Subject text + :param text: Email text + :param html_message: Email text as html + :param from_email: Email address of sender + :param to_email: Email address of recipient + :return: + """ + mail = EmailMessage( + subject, + html_message, + from_email, + [to_email], + ) + mail.fail_silently = False + mail.content_subtype = 'html' + mail.send() + + + diff --git a/backend/src/emissions/fixtures/__init__.py b/backend/src/emissions/fixtures/__init__.py new file mode 100644 index 00000000..54510dbd --- /dev/null +++ b/backend/src/emissions/fixtures/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/fixtures/research_fields.json b/backend/src/emissions/fixtures/research_fields.json new file mode 100644 index 00000000..31345020 --- /dev/null +++ b/backend/src/emissions/fixtures/research_fields.json @@ -0,0 +1 @@ +[{"model": "emissions.researchfield", "pk": 1, "fields": {"field": "Natural Sciences", "subfield": "Mathematics", "id": 1}}, {"model": "emissions.researchfield", "pk": 2, "fields": {"field": "Natural Sciences", "subfield": "Computer and information sciences", "id": 2}}, {"model": "emissions.researchfield", "pk": 3, "fields": {"field": "Natural Sciences", "subfield": "Physical sciences", "id": 3}}, {"model": "emissions.researchfield", "pk": 4, "fields": {"field": "Natural Sciences", "subfield": "Chemical sciences", "id": 4}}, {"model": "emissions.researchfield", "pk": 5, "fields": {"field": "Natural Sciences", "subfield": "Earth and related environmental sciences", "id": 5}}, {"model": "emissions.researchfield", "pk": 6, "fields": {"field": "Natural Sciences", "subfield": "Biological sciences", "id": 6}}, {"model": "emissions.researchfield", "pk": 7, "fields": {"field": "Natural Sciences", "subfield": "Other natural sciences", "id": 7}}, {"model": "emissions.researchfield", "pk": 8, "fields": {"field": "Engineering and Technology", "subfield": "Civil engineering", "id": 8}}, {"model": "emissions.researchfield", "pk": 9, "fields": {"field": "Engineering and Technology", "subfield": "Electrical engineering, electronic engineering, information engineering", "id": 9}}, {"model": "emissions.researchfield", "pk": 10, "fields": {"field": "Engineering and Technology", "subfield": "Mechanical engineering", "id": 10}}, {"model": "emissions.researchfield", "pk": 11, "fields": {"field": "Engineering and Technology", "subfield": "Chemical engineering", "id": 11}}, {"model": "emissions.researchfield", "pk": 12, "fields": {"field": "Engineering and Technology", "subfield": "Materials engineering", "id": 12}}, {"model": "emissions.researchfield", "pk": 13, "fields": {"field": "Engineering and Technology", "subfield": "Medical engineering", "id": 13}}, {"model": "emissions.researchfield", "pk": 14, "fields": {"field": "Engineering and Technology", "subfield": "Environmental engineering", "id": 14}}, {"model": "emissions.researchfield", "pk": 15, "fields": {"field": "Engineering and Technology", "subfield": "Environmental biotechnology", "id": 15}}, {"model": "emissions.researchfield", "pk": 16, "fields": {"field": "Engineering and Technology", "subfield": "Industrial biotechnology", "id": 16}}, {"model": "emissions.researchfield", "pk": 17, "fields": {"field": "Engineering and Technology", "subfield": "Nano-technology", "id": 17}}, {"model": "emissions.researchfield", "pk": 18, "fields": {"field": "Engineering and Technology", "subfield": "Other engineering and technologies", "id": 18}}, {"model": "emissions.researchfield", "pk": 19, "fields": {"field": "Medical and Health Sciences", "subfield": "Basic medicine", "id": 19}}, {"model": "emissions.researchfield", "pk": 20, "fields": {"field": "Medical and Health Sciences", "subfield": "Clinical medicine", "id": 20}}, {"model": "emissions.researchfield", "pk": 21, "fields": {"field": "Medical and Health Sciences", "subfield": "Health sciences", "id": 21}}, {"model": "emissions.researchfield", "pk": 22, "fields": {"field": "Medical and Health Sciences", "subfield": "Health biotechnology", "id": 22}}, {"model": "emissions.researchfield", "pk": 23, "fields": {"field": "Medical and Health Sciences", "subfield": "Other medical sciences", "id": 23}}, {"model": "emissions.researchfield", "pk": 24, "fields": {"field": "Agricultural Sciences", "subfield": "Agriculture, forestry, and fisheries", "id": 24}}, {"model": "emissions.researchfield", "pk": 25, "fields": {"field": "Agricultural Sciences", "subfield": "Animal and diary science", "id": 25}}, {"model": "emissions.researchfield", "pk": 26, "fields": {"field": "Agricultural Sciences", "subfield": "Veterinary science", "id": 26}}, {"model": "emissions.researchfield", "pk": 27, "fields": {"field": "Agricultural Sciences", "subfield": "Agricultural biotechnology", "id": 27}}, {"model": "emissions.researchfield", "pk": 28, "fields": {"field": "Agricultural Sciences", "subfield": "Other agricultural sciences", "id": 28}}, {"model": "emissions.researchfield", "pk": 29, "fields": {"field": "Social Sciences", "subfield": "Psychology", "id": 29}}, {"model": "emissions.researchfield", "pk": 30, "fields": {"field": "Social Sciences", "subfield": "Economics and business", "id": 30}}, {"model": "emissions.researchfield", "pk": 31, "fields": {"field": "Social Sciences", "subfield": "Educational sciences", "id": 31}}, {"model": "emissions.researchfield", "pk": 32, "fields": {"field": "Social Sciences", "subfield": "Sociology", "id": 32}}, {"model": "emissions.researchfield", "pk": 33, "fields": {"field": "Social Sciences", "subfield": "Law", "id": 33}}, {"model": "emissions.researchfield", "pk": 34, "fields": {"field": "Social Sciences", "subfield": "Political science", "id": 34}}, {"model": "emissions.researchfield", "pk": 35, "fields": {"field": "Social Sciences", "subfield": "Social and economic geography", "id": 35}}, {"model": "emissions.researchfield", "pk": 36, "fields": {"field": "Social Sciences", "subfield": "Media and communications", "id": 36}}, {"model": "emissions.researchfield", "pk": 37, "fields": {"field": "Social Sciences", "subfield": "Other social sciences", "id": 37}}, {"model": "emissions.researchfield", "pk": 38, "fields": {"field": "Humanities", "subfield": "History and archeology", "id": 38}}, {"model": "emissions.researchfield", "pk": 39, "fields": {"field": "Humanities", "subfield": "Language and literature", "id": 39}}, {"model": "emissions.researchfield", "pk": 40, "fields": {"field": "Humanities", "subfield": "Philosophy, ethics and religion", "id": 40}}, {"model": "emissions.researchfield", "pk": 41, "fields": {"field": "Humanities", "subfield": "Art (arts, history of arts, performing arts, music)", "id": 41}}, {"model": "emissions.researchfield", "pk": 42, "fields": {"field": "Humanities", "subfield": "Other humanities", "id": 42}}] \ No newline at end of file diff --git a/backend/src/emissions/management/__init__.py b/backend/src/emissions/management/__init__.py index e69de29b..54510dbd 100644 --- a/backend/src/emissions/management/__init__.py +++ b/backend/src/emissions/management/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/management/commands/__init__.py b/backend/src/emissions/management/commands/__init__.py index e69de29b..54510dbd 100644 --- a/backend/src/emissions/management/commands/__init__.py +++ b/backend/src/emissions/management/commands/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" diff --git a/backend/src/emissions/management/commands/create_groups.py b/backend/src/emissions/management/commands/create_groups.py deleted file mode 100644 index 6fb8870d..00000000 --- a/backend/src/emissions/management/commands/create_groups.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Create permission groups -Create permissions (read only) to models for a set of groups -""" - -from django.core.management.base import BaseCommand -from django.contrib.auth.models import Group -from django.contrib.auth.models import Permission -import logging - - -class Command(BaseCommand): - def __init__(self, *args, **kwargs): - super(Command, self).__init__(*args, **kwargs) - - help = "Creates default groups" - - def handle(self, *args, **options): - - group_researcher, created = Group.objects.get_or_create(name='Researcher') - PERMISSIONS = ["add", "change", "delete", "view"] - MODELS = ["business trip"] - if created: - for model in MODELS: - for permission in PERMISSIONS: - name = 'Can {} {}'.format(permission, model) - try: - model_add_perm = Permission.objects.get(name=name) - except Exception: - logging.warning("Permission not found with name '{}'.".format(name)) - continue - group_researcher.permissions.add(model_add_perm) - - group_representative, created = Group.objects.get_or_create(name='Representative') - PERMISSIONS = ["add", "change", "delete", "view"] - MODELS = ["heating", "electricity", "business trip"] - if created: - for model in MODELS: - for permission in PERMISSIONS: - name = 'Can {} {}'.format(permission, model) - #print("Creating {}".format(name)) - try: - model_add_perm = Permission.objects.get(name=name) - except Exception: - logging.warning("Permission not found with name '{}'.".format(name)) - continue - group_representative.permissions.add(model_add_perm) \ No newline at end of file diff --git a/backend/src/emissions/management/commands/create_test_data.py b/backend/src/emissions/management/commands/create_test_data.py new file mode 100644 index 00000000..46adcc41 --- /dev/null +++ b/backend/src/emissions/management/commands/create_test_data.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# create test users from JSON files +import json +import logging +import os + +from django.core.management.base import BaseCommand +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError + +from emissions.models import (CustomUser, WorkingGroup, Institution, ResearchField, Heating, Electricity) + +from co2calculator.co2calculator import (calc_co2_heating, calc_co2_electricity) + +logger = logging.basicConfig() +script_path = os.path.dirname(os.path.realpath(__file__)) + + +ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME") +ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL") +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") + + +class Command(BaseCommand): + """Base Command to populate data""" + + def __init__(self, *args, **kwargs): + """Init class""" + super(Command, self).__init__(*args, **kwargs) + + help = "Creates users for unit tests" + + def handle(self, *args, **options): + """Populate database""" + + # Create super user + try: + CustomUser.objects.create_superuser( + username=ADMIN_USERNAME, + first_name='admin', + last_name='admin', + email=ADMIN_EMAIL, + password=ADMIN_PASSWORD + ) + except IntegrityError: + pass + + config_path = f"{script_path}/../../data/test_data.json" + + with open(config_path) as source: + config_data = json.load(source) + + # Create test users + users = config_data["users"] + for user, user_data in users.items(): + try: + new_user = CustomUser( + username=user_data["username"], + first_name=user_data["first_name"], + last_name=user_data["last_name"], + email=user_data["email"], + ) + new_user.set_password(user_data["password"]) + new_user.save() + # Set user status to verified + status = new_user.status + setattr(status, "verified", True) + status.save(update_fields=["verified"]) + new_user.save() + except IntegrityError as e: + print(e) + except Exception as e: + print(e) + + # Create institutions + institutions = config_data["institutions"] + for institution, institution_data in institutions.items(): + try: + new_institution = Institution( + id=institution_data["id"], + name=institution_data["name"], + city=institution_data["city"], + country=institution_data["country"], + ) + new_institution.save() + + except IntegrityError as e: + print(e) + + # Create working groups + working_groups = config_data["working_groups"] + for working_group, workinggroup_data in working_groups.items(): + try: + representative_user = CustomUser.objects.get(username=workinggroup_data["representative"]) + working_group = WorkingGroup( + id=workinggroup_data['id'], + name=workinggroup_data["name"], + institution=Institution.objects.filter( + id=workinggroup_data["institution"]["id"])[0], + representative=representative_user, + n_employees=workinggroup_data["n_employees"], + field=ResearchField.objects.filter( + id=workinggroup_data["research_field"]["id"] + )[0], + #public=workinggroup_data["public"] + ) + working_group.save() + # Set a representative for the group + representative_user.is_representative = True + representative_user.working_group = working_group + representative_user.save() + except IntegrityError as e: + print(e) + except ValidationError as e: + print(e) + + # Create heating entries + print("Loading heating entries...") + heating_entries = config_data["heating"] + for data in heating_entries: + try: + working_group = WorkingGroup.objects.filter(id=data["working_group_id"])[0] + + # Calculate co2e + co2e = calc_co2_heating( + consumption=data["consumption"], + unit=data["unit"], + fuel_type=data["fuel_type"], + area_share=data["group_share"], + ) + co2e_cap = co2e / working_group.n_employees + + # Store in database + new_heating = Heating( + working_group=working_group, + timestamp=data["timestamp"], + consumption=data["consumption"], + fuel_type=data["fuel_type"], + building=data["building"], + group_share=data["group_share"], + co2e=co2e, + co2e_cap=co2e_cap + ) + new_heating.save() + except IntegrityError as e: + print(e) + + # Create heating entries + print("Loading electricity entries...") + entries = config_data["electricity"] + for data in entries: + try: + working_group = WorkingGroup.objects.filter(id=data["working_group_id"])[0] + + # Calculate co2e + co2e = calc_co2_electricity( + consumption=data["consumption"], + fuel_type=data["fuel_type"], + energy_share=data["group_share"], + ) + + co2e_cap = co2e / working_group.n_employees + + new_electricity = Electricity( + working_group=working_group, + timestamp=data["timestamp"], + consumption=data["consumption"], + fuel_type=data["fuel_type"], + building=data["building"], + group_share=data["group_share"], + co2e=co2e, + co2e_cap=co2e_cap, + ) + new_electricity.save() + + except IntegrityError as e: + print(e) + + + # Create business trips + # business_trips = config_data["business_trips"] + # for business_trip, businesstrip_data in business_trips.items(): + # try: + # transportation_mode = ..., + # start = ..., + # destination = ..., + # distance = ..., + # size = ..., + # fuel_type = ..., + # occupancy = ..., + # seating = ..., + # passengers = ..., + # roundtrip = ... \ No newline at end of file diff --git a/backend/src/emissions/management/commands/load_institutions.py b/backend/src/emissions/management/commands/load_institutions.py new file mode 100644 index 00000000..993079be --- /dev/null +++ b/backend/src/emissions/management/commands/load_institutions.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Polulate database with dummy data""" + +from django.core.management.base import BaseCommand +from django.db.utils import IntegrityError + + +import pandas as pd +import os +import logging + +from emissions.models import Institution + + +# Load settings from ./.env file +#load_dotenv(find_dotenv()) + +logger = logging.basicConfig() + +script_path = os.path.dirname(os.path.realpath(__file__)) + + +class Command(BaseCommand): + """Base Command to populate data""" + + def __init__(self, *args, **kwargs): + """Init class""" + super(Command, self).__init__(*args, **kwargs) + + help = "Seeds the database." + + def handle(self, *args, **options): + """Populate database""" + + # LOAD INSTITUTIONS - GERMAN ONLY RIGHT NOW -------------------------------------------------------- + print("Loading institutions ...") + grid = pd.read_csv(f"{script_path}/../../data/grid.csv") + grid = grid.loc[grid.Country == "Germany"] + for inst in grid.iterrows(): + try: + new_institution = Institution( + name=inst[1].Name, + city=inst[1].City, + state=inst[1].State, + country=inst[1].Country, + ) + new_institution.save() + except IntegrityError: + print("Institutions already loaded.") + break + del grid diff --git a/backend/src/emissions/management/commands/populate_data.py b/backend/src/emissions/management/commands/populate_data.py index dc3e854e..149daf6c 100644 --- a/backend/src/emissions/management/commands/populate_data.py +++ b/backend/src/emissions/management/commands/populate_data.py @@ -1,139 +1,179 @@ -""" -Create permission groups -Create permissions (read only) to models for a set of groups -""" +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Polulate database with dummy data""" from django.core.management.base import BaseCommand -from django.contrib.auth.models import Group from django.db.models import Sum from django.db.utils import IntegrityError -from emissions.models import User, WorkingGroup, BusinessTrip, Heating, Electricity, Institution, Commuting, CommutingGroup -from co2calculator.co2calculator import calc_co2_heating, calc_co2_electricity, calc_co2_commuting, calc_co2_businesstrip + import numpy as np import pandas as pd import os import logging -from django.contrib.auth.management.commands import createsuperuser -from co2calculator.co2calculator import CommutingTransportationMode, BusinessTripTransportationMode, HeatingFuel, ElectricityFuel -logger = logging.basicConfig() +from emissions.models import ( + CustomUser, + WorkingGroup, + BusinessTrip, + Heating, + Electricity, + Institution, + Commuting, + CommutingGroup, + ResearchField, +) + +from co2calculator.co2calculator.calculate import ( + calc_co2_heating, + calc_co2_electricity, + calc_co2_commuting, +) + +from co2calculator.co2calculator.constants import ( + TransportationMode, + HeatingFuel, + ElectricityFuel, +) + +# Load settings from ./.env file +#load_dotenv(find_dotenv()) +logger = logging.basicConfig() script_path = os.path.dirname(os.path.realpath(__file__)) class Command(BaseCommand): + """Base Command to populate data""" + def __init__(self, *args, **kwargs): + """Init class""" super(Command, self).__init__(*args, **kwargs) - help = 'Seeds the database.' + help = "Seeds the database." def handle(self, *args, **options): - - # Create super user - try: - User.objects.create_superuser("admin", 'admin@admin.com', 'adminpass') - except IntegrityError: - pass - - # LOAD INSTITUTIONS - GERMAN ONLY RIGHT NOW -------------------------------------------------------- - print("Loading institutions ...") - grid = pd.read_csv(f"{script_path}/../../data/grid.csv") - grid = grid.loc[grid.Country == "Germany"] - for inst in grid.iterrows(): - try: - new_institution = Institution(name=inst[1].Name, - city=inst[1].City, - state=inst[1].State, - country=inst[1].Country) - new_institution.save() - except IntegrityError: - print("Institutions already loaded.") - break - del grid + """Populate database""" # CREATE USERS -------------------------------------------------------- print("Loading users ...") user_data = pd.read_csv(f"{script_path}/../../data/users.csv") for usr in user_data.iterrows(): try: - new_user = User(username=usr[1].first_name + usr[1].last_name, - first_name=usr[1].first_name, - last_name=usr[1].last_name, - email=f"{usr[1].first_name}.{usr[1].last_name}@uni-hd.de", - password="password1234") + new_user = CustomUser( + first_name=usr[1].first_name, + last_name=usr[1].last_name, + email=f"{usr[1].first_name}.{usr[1].last_name}@uni-hd.de", + ) + new_user.set_password("test_password") + new_user.save() + status = new_user.status + setattr(status, "verified", True) + status.save(update_fields=["verified"]) new_user.save() except IntegrityError: print("Users already exist.") break # CREATE WORKING GROUPS -------------------------------------------------------- - environmental_search = WorkingGroup.objects.filter(name="Environmental Research Group") + environmental_search = WorkingGroup.objects.filter( + name="Environmental Research Group" + ) if len(environmental_search) == 0: - wg_environmental = WorkingGroup(name="Environmental Research Group", - institution=Institution.objects.filter(name="Heidelberg University", - city="Heidelberg", - country="Germany")[0], - representative=User.objects.get(username="LarsWiese"), - n_employees=20) + wg_environmental = WorkingGroup( + name="Environmental Research Group", + institution=Institution.objects.filter( + name="Heidelberg University", city="Heidelberg", country="Germany" + )[0], + representative=CustomUser.objects.get(email="Lars.Wiese@uni-hd.de"), + n_employees=20, + field=ResearchField.objects.filter( + field="Natural Sciences", + subfield="Earth and related environmental sciences", + )[0], + is_public=True + ) wg_environmental.save() + else: wg_environmental = environmental_search[0] biomed_search = WorkingGroup.objects.filter(name="Biomedical Research Group") if len(biomed_search) == 0: - wg_biomed = WorkingGroup(name="Biomedical Research Group", - institution=Institution.objects.filter(name="Heidelberg University", - city="Heidelberg", - country="Germany")[0], - representative=User.objects.get(username="KarenAnderson"), - n_employees=15) + testuser_representative = CustomUser.objects.get(email="test3@pledge4future.org") + wg_biomed = WorkingGroup( + name="Biomedical Research Group", + institution=Institution.objects.filter( + name="Heidelberg University", city="Heidelberg", country="Germany" + )[0], + representative=testuser_representative, + n_employees=15, + field=ResearchField.objects.filter( + field="Natural Sciences", subfield="Biological sciences" + )[0], + ) wg_biomed.save() + testuser_representative.is_representative = True + testuser_representative.working_group = wg_biomed + testuser_representative.save() else: wg_biomed = biomed_search[0] # Update working groups of users for usr in user_data.iterrows(): - user_found = User.objects.filter(username=usr[1].first_name + usr[1].last_name)[0] + user_found = CustomUser.objects.filter( + first_name=usr[1].first_name, last_name=usr[1].last_name + )[0] wg_search = WorkingGroup.objects.filter(name=usr[1].working_group) user_found.working_group = wg_search[0] user_found.save() del user_data # CREATE FAKE DATA - dates = np.arange(np.datetime64('2019-01'), - np.datetime64('2021-01'), - np.timedelta64(1, "M")).astype( 'datetime64[D]') + dates = np.arange( + np.datetime64("2019-01"), np.datetime64("2021-01"), np.timedelta64(1, "M") + ).astype("datetime64[D]") # CREATE ELECTRICITY OBJECTS -------------------------------------------------------- if len(Electricity.objects.all()) == 0: print("Loading electricity data ...") - consumptions = np.random.uniform(low=8000, high=12000, size=24).astype("int") + consumptions = np.random.uniform(low=8000, high=12000, size=24).astype( + "int" + ) for c, d in zip(consumptions, dates): co2e = calc_co2_electricity(c, "german_energy_mix") co2e_cap = co2e / wg_biomed.n_employees - new_electricity = Electricity(working_group=wg_biomed, - timestamp=str(d), - consumption=c, - fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_electricity = Electricity( + working_group=wg_biomed, + timestamp=str(d), + consumption=c, + fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_electricity.save() - consumptions = np.random.uniform(low=11000, high=15000, size=24).astype("int") + consumptions = np.random.uniform(low=11000, high=15000, size=24).astype( + "int" + ) for c, d in zip(consumptions, dates): - co2e = calc_co2_electricity(c, "german_energy_mix") + co2e = calc_co2_electricity( + consumption=c, fuel_type="german_energy_mix" + ) co2e_cap = co2e / wg_environmental.n_employees - new_electricity = Electricity(working_group=wg_environmental, - timestamp=str(d), - consumption=c, - fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_electricity = Electricity( + working_group=wg_environmental, + timestamp=str(d), + consumption=c, + fuel_type=ElectricityFuel.GERMAN_ENERGY_MIX.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_electricity.save() # CREATE HEATING OBJECTS -------------------------------------------------------- @@ -142,60 +182,73 @@ def handle(self, *args, **options): consumptions = np.random.uniform(low=1400, high=2200, size=24).astype("int") for c, d in zip(consumptions, dates): - co2e = calc_co2_heating(consumption=c, unit="l", fuel_type="oil", area_share=1) + co2e = calc_co2_heating( + consumption=c, unit="l", fuel_type="oil", area_share=1 + ) co2e_cap = co2e / wg_biomed.n_employees - new_heating = Heating(working_group=wg_biomed, - timestamp=str(d), - consumption=c, - fuel_type=HeatingFuel.OIL, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_heating = Heating( + working_group=wg_biomed, + timestamp=str(d), + consumption=c, + fuel_type=HeatingFuel.OIL.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_heating.save() consumptions = np.random.uniform(low=1000, high=1500, size=24).astype("int") for c, d in zip(consumptions, dates): - co2e = calc_co2_heating(c, "l", "oil", area_share=1) + co2e = calc_co2_heating( + consumption=c, unit="l", fuel_type="oil", area_share=1 + ) co2e_cap = co2e / wg_environmental.n_employees - new_heating = Heating(working_group=wg_environmental, - timestamp=str(d), - consumption=c, - fuel_type=HeatingFuel.OIL, - building="348", - group_share=1, - co2e=co2e, - co2e_cap=co2e_cap) + new_heating = Heating( + working_group=wg_environmental, + timestamp=str(d), + consumption=c, + fuel_type=HeatingFuel.OIL.name.lower(), + building="348", + group_share=1, + co2e=co2e, + co2e_cap=co2e_cap, + ) new_heating.save() # CREATE BUSINESS TRIPS -------------------------------------------------------- if len(BusinessTrip.objects.all()) == 0: print("Loading business trip data ...") - modes = [BusinessTripTransportationMode.PLANE, - BusinessTripTransportationMode.CAR, - BusinessTripTransportationMode.TRAIN, - BusinessTripTransportationMode.BUS] + modes = [ + TransportationMode.PLANE, + TransportationMode.CAR, + TransportationMode.TRAIN, + TransportationMode.BUS, + ] - dates = np.arange(np.datetime64('2019-01-15'), - np.datetime64('2021-01-15'), - np.timedelta64(30, "D")).astype('datetime64[D]') + dates = np.arange( + np.datetime64("2019-01-15"), + np.datetime64("2021-01-15"), + np.timedelta64(30, "D"), + ).astype("datetime64[D]") - for usr in User.objects.all(): - if usr.working_group is None: - continue + for usr in CustomUser.objects.all(): + # if usr.working_group is None: + # continue for d in dates: co2e = co2e_cap = float(np.random.randint(50, 1000, 1)) - new_trip = BusinessTrip(user=usr, - working_group=usr.working_group, - distance=np.random.randint(100, 10000, 1), - co2e=co2e, - timestamp=str(d), - transportation_mode=np.random.choice(modes, 1)[0].value) + new_trip = BusinessTrip( + user=usr, + working_group=usr.working_group, + distance=np.random.randint(100, 10000, 1), + co2e=co2e, + timestamp=str(d), + transportation_mode=np.random.choice(modes, 1), + ) new_trip.save() - if len(Commuting.objects.all()) == 0: print("Loading commuting data ...") @@ -203,105 +256,121 @@ def handle(self, *args, **options): WEEKS_PER_MONTH = 4.34524 WEEKS_PER_YEAR = 52.1429 - dates_2019 = np.arange(np.datetime64('2019-01', "M"), - np.datetime64('2020-01', "M"), - np.timedelta64(1, "M")).astype('datetime64[D]') - dates_2020 = np.arange(np.datetime64('2020-01', "M"), - np.datetime64('2021-01', "M"), - np.timedelta64(1, "M")).astype('datetime64[D]') - - for usr in User.objects.all(): - if usr.working_group is None: + dates_2019 = np.arange( + np.datetime64("2019-01", "M"), + np.datetime64("2020-01", "M"), + np.timedelta64(1, "M"), + ).astype("datetime64[D]") + dates_2020 = np.arange( + np.datetime64("2020-01", "M"), + np.datetime64("2021-01", "M"), + np.timedelta64(1, "M"), + ).astype("datetime64[D]") + #print(dates_2019) + + for usr in CustomUser.objects.all(): + if usr.is_superuser: continue distance = np.random.randint(0, 20, 1) transportation_mode = "bicycle" for d_2019 in range(len(dates_2019) - 1): - from_timestamp = dates_2019[d_2019] - to_timestamp = dates_2019[d_2019+1] + timestamp = dates_2019[d_2019] # calculate co2 - weekly_co2e = calc_co2_commuting(transportation_mode=transportation_mode, - weekly_distance=distance) + weekly_co2e = calc_co2_commuting( + transportation_mode=transportation_mode, + weekly_distance=distance, + ) # Calculate monthly co2 - monthly_co2e = WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e - dates = np.arange(np.datetime64(from_timestamp, "M"), - np.datetime64(to_timestamp, "M") + np.timedelta64(1, 'M'), - np.timedelta64(1, "M")).astype('datetime64[D]') - for d in dates: - commuting_instance = Commuting(timestamp=str(d), - distance=distance, - transportation_mode=transportation_mode, - co2e=monthly_co2e, - user=usr, - working_group=usr.working_group) - commuting_instance.save() - - # Update emissions of working group for date and transportation mode - entries = Commuting.objects.filter(working_group=usr.working_group, - transportation_mode=transportation_mode, - timestamp=str(d)) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - - co2e_cap = group_data["co2e"] / usr.working_group.n_employees - commuting_group_instance = CommutingGroup(working_group=usr.working_group, - timestamp=str(d), - transportation_mode=transportation_mode, - n_employees=usr.working_group.n_employees, - co2e=group_data["co2e"], - co2e_cap=co2e_cap, - distance=group_data["distance"]) - commuting_group_instance.save() + monthly_co2e = ( + WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e + ) + + commuting_instance = Commuting( + timestamp=str(timestamp), + distance=distance, + transportation_mode=transportation_mode, + co2e=monthly_co2e, + user=usr, + working_group=usr.working_group, + ) + commuting_instance.save() + + if usr.working_group is None: + continue + + # Update emissions of working group for date and transportation mode + entries = Commuting.objects.filter( + working_group=usr.working_group, + transportation_mode=transportation_mode, + timestamp=str(timestamp), + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + + co2e_cap = group_data["co2e"] / usr.working_group.n_employees + commuting_group_instance = CommutingGroup( + working_group=usr.working_group, + timestamp=str(timestamp), + transportation_mode=transportation_mode, + n_employees=usr.working_group.n_employees, + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + distance=group_data["distance"], + ) + commuting_group_instance.save() transportation_mode = "car" for d_2020 in range(len(dates_2020) - 1): - from_timestamp = dates_2020[d_2020] - to_timestamp = dates_2020[d_2020 + 1] + timestamp = dates_2020[d_2020] + # to_timestamp = dates_2020[d_2020 + 1] # calculate co2 - weekly_co2e = calc_co2_commuting(transportation_mode=transportation_mode, - weekly_distance=distance, - passengers=1, - size="medium", - fuel_type="gasoline") + weekly_co2e = calc_co2_commuting( + transportation_mode=transportation_mode, + weekly_distance=distance, + passengers=1, + size="medium", + fuel_type="gasoline", + ) # Calculate monthly co2 - monthly_co2e = WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e - dates = np.arange(np.datetime64(from_timestamp, "M"), - np.datetime64(to_timestamp, "M") + np.timedelta64(1, 'M'), - np.timedelta64(1, "M")).astype('datetime64[D]') - for d in dates: - commuting_instance = Commuting(timestamp=str(d), - distance=distance, - transportation_mode=transportation_mode, - co2e=monthly_co2e, - user=usr, - working_group=usr.working_group) - commuting_instance.save() - - # Update emissions of working group for date and transportation mode - entries = Commuting.objects.filter(working_group=usr.working_group, - transportation_mode=transportation_mode, - timestamp=str(d)) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - - co2e_cap = group_data["co2e"] / usr.working_group.n_employees - commuting_group_instance = CommutingGroup(working_group=usr.working_group, - timestamp=str(d), - transportation_mode=transportation_mode, - n_employees=usr.working_group.n_employees, - co2e=group_data["co2e"], - co2e_cap=co2e_cap, - distance=group_data["distance"]) - commuting_group_instance.save() - - + monthly_co2e = ( + WEEKS_PER_MONTH * (workweeks / WEEKS_PER_YEAR) * weekly_co2e + ) + + commuting_instance = Commuting( + timestamp=str(timestamp), + distance=distance, + transportation_mode=transportation_mode, + co2e=monthly_co2e, + user=usr, + working_group=usr.working_group, + ) + commuting_instance.save() + + if usr.working_group is None: + continue + + # Update emissions of working group for date and transportation mode + entries = Commuting.objects.filter( + working_group=usr.working_group, + transportation_mode=transportation_mode, + timestamp=str(timestamp), + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + + co2e_cap = group_data["co2e"] / usr.working_group.n_employees + commuting_group_instance = CommutingGroup( + working_group=usr.working_group, + timestamp=str(timestamp), + transportation_mode=transportation_mode, + n_employees=usr.working_group.n_employees, + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + distance=group_data["distance"], + ) + commuting_group_instance.save() diff --git a/backend/src/wepledge/__init__.py b/backend/src/emissions/migrations/__init__.py similarity index 100% rename from backend/src/wepledge/__init__.py rename to backend/src/emissions/migrations/__init__.py diff --git a/backend/src/emissions/models.py b/backend/src/emissions/models.py deleted file mode 100644 index 253fa075..00000000 --- a/backend/src/emissions/models.py +++ /dev/null @@ -1,342 +0,0 @@ -import uuid - -from django.core.validators import MinValueValidator, MaxValueValidator -from django.db import models -from django.contrib.auth.models import AbstractUser -from django.db.models import Sum -from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ValidationError - -from co2calculator.co2calculator import CommutingTransportationMode, BusinessTripTransportationMode, HeatingFuel, \ - ElectricityFuel, Unit - - -class User(AbstractUser): - """ - Researcher. May be normal user or a group representative - """ - email = models.EmailField(blank=False, max_length=255, verbose_name="email", unique=True) - username = models.CharField(max_length=100, unique=True) - first_name = models.CharField(max_length=25, blank=True) - last_name = models.CharField(max_length=25, blank=True) - working_group = models.ForeignKey('WorkingGroup', on_delete=models.SET_NULL, null=True, blank=True) - is_representative = models.BooleanField(default=False) - - USERNAME_FIELD = 'email' - EMAIL_FIELD = "email" - REQUIRED_FIELDS = ['username'] - - def __str__(self): - return self.username - - -class Institution(models.Model): - """ - Top level research institution, e.g. Heidelberg University - """ - name = models.CharField(max_length=200, null=False, blank=False) - city = models.CharField(max_length=100, null=False, blank=False) - state = models.CharField(max_length=100, null=True) - country = models.CharField(max_length=100, null=False, blank=False) - inst_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - readonly_fields = ('inst_id',) - - class Meta: - unique_together = ("name", "city", "country") - - def __str__(self): - return f"{self.name}, {self.city}, {self.country}" - - -class WorkingGroup(models.Model): - """ - Working group - """ - name = models.CharField(max_length=200, blank=False) - institution = models.ForeignKey(Institution, on_delete=models.PROTECT, null=True) - representative = models.ForeignKey(User, on_delete=models.PROTECT, null=True) - n_employees = models.IntegerField(null=True, blank=True) - research_field = models.CharField(null=True, blank=True, max_length=200) - group_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - readonly_fields = ('group_id',) - - class Meta: - unique_together = ("name", "institution") - - def clean(self, *args, **kwargs): - """ - Validate that the representative of the working group is member of the working group - :param args: - :param kwargs: - :return: - """ - # add custom validation here - if (self.representative.working_group != self) and (self.representative.working_group is not None): - raise ValidationError(_('New representative is not a member of this working group.'), code='invalid') - super().clean(*args, **kwargs) - - def save(self, *args, **kwargs): - """ - Updates the user who is the representative of the working group - :param args: - :param kwargs: - :return: - """ - self.full_clean() - super(WorkingGroup, self).save(*args, **kwargs) - - def __str__(self): - return f"{self.name}, {self.institution.name}, {self.institution.city}, {self.institution.country}" - - -class CommutingGroup(models.Model): - """ - Monthly emissions from commuting per working group - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - n_employees = models.IntegerField(null=False) - transportation_mode = models.CharField(max_length=30) - distance = models.FloatField(null=True) - co2e = models.FloatField() - co2e_cap = models.FloatField() - - def __str__(self): - return f"{self.working_group.name}, {self.transportation_mode}, {self.timestamp}" - - -class Commuting(models.Model): - """ - CO2 emissions from commuting per month - """ - user = models.ForeignKey(User, on_delete=models.CASCADE) - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - co2e = models.FloatField() - distance = models.FloatField() - transportation_choices = [(x.name, x.value) for x in CommutingTransportationMode] - transportation_mode = models.CharField(max_length=15, - choices=transportation_choices, - blank=False, - ) - - def __str__(self): - return f"{self.user.username}, {self.transportation_mode}, {self.timestamp}" - -class Heating(models.Model): - """ - Heating consumption per year - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption_kwh = models.FloatField(null=False) - timestamp = models.DateField(null=False) - - PUMPAIR = 'PUMPAIR' - PUMPGROUND = 'PUMPGROUND' - PUMPWATER = 'PUMPWATER' - LIQUID = 'LIQUID' - OIL = 'OIL' - PELLETS = 'PELLETS' - SOLAR = 'SOLAR' - WOODCHIPS = 'WOODCHIPS' - ELECTRICITY = 'ELECTRICITY' - GAS = 'GAS' - fuel_type_choices = [(PUMPAIR, 'Pump air'), (PUMPGROUND, 'Pump ground'), (PUMPWATER, 'Pump water'), - (LIQUID, 'Liquid'), (OIL, 'Oil'), (PELLETS, 'Pellets'), (SOLAR, 'Solar'), - (WOODCHIPS, 'Woodchips'), - (ELECTRICITY, 'Electricity'), (GAS, 'Gas')] - fuel_type = models.CharField(max_length=20, choices=fuel_type_choices, blank=False) - co2e = models.DecimalField(max_digits=10, decimal_places=1) - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}" - - -class Electricity(models.Model): - """ - Electricity consumption per year - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption_kwh = models.FloatField(null=False) - timestamp = models.DateField(null=False) - - GERMAN_ELECTRICITY_MIX = 'german energy mix' # must be same as in data of co2calculator - #GREEN_ENERGY = 'GREEN_ENERGY' - SOLAR = 'solar' - fuel_type_choices = [(GERMAN_ELECTRICITY_MIX, 'German Energy Mix'), - #(GREEN_ENERGY, 'Green energy'), - (SOLAR, 'Solar')] - fuel_type = models.CharField(max_length=30, choices=fuel_type_choices, blank=False) - - co2e = models.DecimalField(max_digits=10, decimal_places=1) - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}" - - -class BusinessTripGroup(models.Model): - """ - Monthly business trip emissions per working group - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - n_employees = models.IntegerField(null=False) - transportation_choices = [(x.name, x.value) for x in BusinessTripTransportationMode] - transportation_mode = models.CharField(max_length=10, - choices=transportation_choices, - blank=False, - ) - distance = models.FloatField() - co2e = models.FloatField() - co2e_cap = models.FloatField() - - class Meta: - unique_together = ("working_group", "timestamp", "transportation_mode") - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}, {self.transportation_mode}" - - -class BusinessTrip(models.Model): - """ - Business trip - """ - user = models.ForeignKey(User, on_delete=models.CASCADE) - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) - timestamp = models.DateField(null=False) - distance = models.FloatField() - co2e = models.FloatField() - transportation_choices = [(x.name, x.value) for x in BusinessTripTransportationMode] - transportation_mode = models.CharField(max_length=10, - choices=transportation_choices, - blank=False, - ) - range_category = models.CharField(max_length=50) - - def save(self, *args, **kwargs): - # Calculate monthly co2 - super(BusinessTrip, self).save(*args, **kwargs) - if self.working_group is None: - return - - year = self.timestamp[:4] - month = self.timestamp[5:7] - entries = BusinessTrip.objects.filter(working_group=self.working_group, - timestamp__year=year, - timestamp__month=month, - transportation_mode=self.transportation_mode) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - co2e_cap = group_data["co2e"] / self.working_group.n_employees - - try: - obj = BusinessTripGroup.objects.get(working_group=self.working_group, - timestamp="{0}-{1}-01".format(year, month), - transportation_mode=self.transportation_mode) - obj.n_employees = self.working_group.n_employees - obj.distance = group_data["distance"] - obj.co2e = group_data["co2e"] - obj.co2e_cap = co2e_cap - obj.save() - except BusinessTripGroup.DoesNotExist: - BusinessTripGroup( - working_group=self.working_group, - timestamp="{0}-{1}-01".format(year, month), - transportation_mode=self.transportation_mode, - n_employees=self.working_group.n_employees, - distance=group_data["distance"], - co2e=group_data["co2e"], - co2e_cap=co2e_cap).save() - - def delete(self): - # Calculate monthly co2 - super(BusinessTrip, self).delete() - entries = BusinessTrip.objects.filter(working_group=self.working_group, - timestamp__year=self.timestamp.year, - timestamp__month=self.timestamp.month, - transportation_mode=self.transportation_mode) - print(entries) - if len(entries) == 0: - co2e = 0 - co2e_cap = 0 - distance = 0 - else: - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } - group_data = entries.aggregate(**metrics) - co2e = group_data["co2e"] - distance = group_data["distance"] - co2e_cap = co2e / self.working_group.n_employees - - try: - obj = BusinessTripGroup.objects.get(working_group=self.working_group, - timestamp="{0}-{1}-01".format(self.timestamp.year, - self.timestamp.month), - transportation_mode=self.transportation_mode) - obj.n_employees = self.working_group.n_employees - obj.distance = distance - obj.co2e = co2e - obj.co2e_cap = co2e_cap - obj.save() - except BusinessTripGroup.DoesNotExist: - BusinessTripGroup( - working_group=self.working_group, - timestamp="{0}-{1}-01".format(self.timestamp.year, self.timestamp.month), - transportation_mode=self.transportation_mode, - n_employees=self.working_group.n_employees, - distance=distance, - co2e=co2e, - co2e_cap=co2e_cap).save() - - - def __str__(self): - return f"{self.user.username}, {self.timestamp}" - - -class Heating(models.Model): - """ - Heating consumption per year - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption = models.FloatField(null=False, validators=[MinValueValidator(0.0)]) - timestamp = models.DateField(null=False) - building = models.CharField(null=False, max_length=30) - group_share = models.FloatField(null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - fuel_type_choices = [(x.name, x.value) for x in HeatingFuel] - fuel_type = models.CharField(max_length=20, choices=fuel_type_choices, blank=False) - co2e = models.FloatField() - co2e_cap = models.FloatField() - - class Meta: - unique_together = ("working_group", "timestamp", "fuel_type", "building") - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" - - -class Electricity(models.Model): - """ - Electricity consumption for a timestamp - """ - working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) - consumption = models.FloatField(null=False) - timestamp = models.DateField(null=False) - building = models.CharField(null=False, max_length=30) - group_share = models.FloatField(null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - fuel_type_choices = [(x.name, x.value) for x in ElectricityFuel] - fuel_type = models.CharField(max_length=40, choices=fuel_type_choices, blank=False) - - co2e = models.FloatField() - co2e_cap = models.FloatField() - - class Meta: - unique_together = ("working_group", "timestamp", "fuel_type", "building") - - def __str__(self): - return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" - diff --git a/backend/src/emissions/models/__init__.py b/backend/src/emissions/models/__init__.py new file mode 100644 index 00000000..4978d134 --- /dev/null +++ b/backend/src/emissions/models/__init__.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" Django models for handling co2 emission data """ + +from .customUser import CustomUser +from .researchField import ResearchField +from .institution import Institution +from .workingGroup import WorkingGroup +from .businessTrip import (BusinessTrip, BusinessTripGroup) +from .commuting import (Commuting, CommutingGroup) +from .electricity import Electricity +from .heating import Heating +from .workingGroupJoinRequest import WorkingGroupJoinRequest \ No newline at end of file diff --git a/backend/src/emissions/models/businessTrip.py b/backend/src/emissions/models/businessTrip.py new file mode 100644 index 00000000..95dac6b7 --- /dev/null +++ b/backend/src/emissions/models/businessTrip.py @@ -0,0 +1,144 @@ + +from django.db import models +from django.db.models import Sum + + +from emissions.models import (CustomUser, WorkingGroup) + +from co2calculator.co2calculator.constants import TransportationMode + + +class BusinessTripGroup(models.Model): + """Monthly business trip emissions per working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + n_employees = models.IntegerField(null=False) + transportation_choices = [(x.name, x.value) for x in TransportationMode] + transportation_mode = models.CharField( + max_length=10, + choices=transportation_choices, + blank=False, + ) + distance = models.FloatField() + co2e = models.FloatField() + co2e_cap = models.FloatField() + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("working_group", "timestamp", "transportation_mode") + + def __str__(self): + return ( + f"{self.working_group.name}, {self.timestamp}, {self.transportation_mode}" + ) + + +class BusinessTrip(models.Model): + """Business trip of an employee""" + + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + distance = models.FloatField() + co2e = models.FloatField() + transportation_choices = [(x.name, x.value) for x in TransportationMode] + transportation_mode = models.CharField( + max_length=10, + choices=transportation_choices, + blank=False, + ) + range_category = models.CharField(max_length=50) + + def save(self, *args, **kwargs): + """Recalculate the emission of the respective working group when a user adds a business trip""" + # Calculate monthly co2 + super(BusinessTrip, self).save(*args, **kwargs) + if self.working_group is None: + return + + year = self.timestamp.year + month = self.timestamp.month + entries = BusinessTrip.objects.filter( + working_group=self.working_group, + timestamp__year=year, + timestamp__month=month, + transportation_mode=self.transportation_mode, + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + co2e_cap = group_data["co2e"] / self.working_group.n_employees + + try: + obj = BusinessTripGroup.objects.get( + working_group=self.working_group, + timestamp="{0}-{1}-01".format(year, month), + transportation_mode=self.transportation_mode, + ) + obj.n_employees = self.working_group.n_employees + obj.distance = group_data["distance"] + obj.co2e = group_data["co2e"] + obj.co2e_cap = co2e_cap + obj.save() + except BusinessTripGroup.DoesNotExist: + BusinessTripGroup( + working_group=self.working_group, + timestamp="{0}-{1}-01".format(year, month), + transportation_mode=self.transportation_mode, + n_employees=self.working_group.n_employees, + distance=group_data["distance"], + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + ).save() + + def delete(self): + """Recalculate the emission of the respective working group when a user delets a business trip""" + # Calculate monthly co2 + super(BusinessTrip, self).delete() + entries = BusinessTrip.objects.filter( + working_group=self.working_group, + timestamp__year=self.timestamp.year, + timestamp__month=self.timestamp.month, + transportation_mode=self.transportation_mode, + ) + print(entries) + if len(entries) == 0: + co2e = 0 + co2e_cap = 0 + distance = 0 + else: + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} + group_data = entries.aggregate(**metrics) + co2e = group_data["co2e"] + distance = group_data["distance"] + co2e_cap = co2e / self.working_group.n_employees + + try: + obj = BusinessTripGroup.objects.get( + working_group=self.working_group, + timestamp="{0}-{1}-01".format( + self.timestamp.year, self.timestamp.month + ), + transportation_mode=self.transportation_mode, + ) + obj.n_employees = self.working_group.n_employees + obj.distance = distance + obj.co2e = co2e + obj.co2e_cap = co2e_cap + obj.save() + except BusinessTripGroup.DoesNotExist: + BusinessTripGroup( + working_group=self.working_group, + timestamp="{0}-{1}-01".format( + self.timestamp.year, self.timestamp.month + ), + transportation_mode=self.transportation_mode, + n_employees=self.working_group.n_employees, + distance=distance, + co2e=co2e, + co2e_cap=co2e_cap, + ).save() + + def __str__(self): + return f"{self.user.first_name} {self.user.last_name}, {self.transportation_mode}, {self.timestamp}" \ No newline at end of file diff --git a/backend/src/emissions/models/commuting.py b/backend/src/emissions/models/commuting.py new file mode 100644 index 00000000..d6617c3c --- /dev/null +++ b/backend/src/emissions/models/commuting.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Commuting Models """ + + +from django.db import models + +from emissions.models import (CustomUser, WorkingGroup) + +from co2calculator.co2calculator.constants import TransportationMode + + +class CommutingGroup(models.Model): + """Monthly emissions from commuting per working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + n_employees = models.IntegerField(null=False) + transportation_mode = models.CharField(max_length=30) + distance = models.FloatField(null=True) + co2e = models.FloatField() + co2e_cap = models.FloatField() + + def __str__(self): + return ( + f"{self.working_group.name}, {self.transportation_mode}, {self.timestamp}" + ) + + +class Commuting(models.Model): + """Monthly emissions from commuting of an employee""" + + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=True) + timestamp = models.DateField(null=False) + co2e = models.FloatField() + distance = models.FloatField() + transportation_choices = [(x.name, x.value) for x in TransportationMode] + transportation_mode = models.CharField( + max_length=15, + choices=transportation_choices, + blank=False, + ) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("user", "timestamp", "transportation_mode") + + def __str__(self): + return f"{self.user.first_name} {self.user.last_name}, {self.transportation_mode}, {self.timestamp}" diff --git a/backend/src/emissions/models/customUser.py b/backend/src/emissions/models/customUser.py new file mode 100644 index 00000000..16fa4b0f --- /dev/null +++ b/backend/src/emissions/models/customUser.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Custom User Model """ + +from django.db import models +from django.contrib.auth.models import AbstractUser + +import uuid + +class CustomUser(AbstractUser): + """Custom user model""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + email = models.EmailField( + blank=False, max_length=255, verbose_name="email", unique=True + ) + username = models.CharField(max_length=100, unique=True) + first_name = models.CharField(max_length=25, blank=True) + last_name = models.CharField(max_length=25, blank=True) + title_choices = [("PROF", "Prof."), ("DR", "Dr.")] + academic_title = models.CharField(max_length=10, choices=title_choices, blank=True) + working_group = models.ForeignKey("WorkingGroup", on_delete=models.SET_NULL, null=True, blank=True) + is_representative = models.BooleanField(default=False) + + USERNAME_FIELD = "email" + EMAIL_FIELD = "email" + REQUIRED_FIELDS = [] + + def __str__(self): + return f"{self.first_name} {self.last_name}" \ No newline at end of file diff --git a/backend/src/emissions/models/electricity.py b/backend/src/emissions/models/electricity.py new file mode 100644 index 00000000..e72f112d --- /dev/null +++ b/backend/src/emissions/models/electricity.py @@ -0,0 +1,37 @@ + +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Electricity Model """ + +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator + +from emissions.models import WorkingGroup + +from co2calculator.co2calculator.constants import ElectricityFuel + + +class Electricity(models.Model): + """Monthly emissions from electricity consumption of a working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) + consumption = models.FloatField(null=False) + timestamp = models.DateField(null=False) + building = models.CharField(null=False, max_length=30) + group_share = models.FloatField( + null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)] + ) + fuel_type_choices = [(x.name, x.value) for x in ElectricityFuel] + fuel_type = models.CharField(max_length=40, choices=fuel_type_choices, blank=False) + + co2e = models.FloatField() + co2e_cap = models.FloatField() + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("working_group", "timestamp", "fuel_type", "building") + + def __str__(self): + return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" \ No newline at end of file diff --git a/backend/src/emissions/models/heating.py b/backend/src/emissions/models/heating.py new file mode 100644 index 00000000..b5b03cef --- /dev/null +++ b/backend/src/emissions/models/heating.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Heating Model """ + +from django.db import models +from django.core.validators import MinValueValidator, MaxValueValidator + +from emissions.models import WorkingGroup + +from co2calculator.co2calculator.constants import HeatingFuel + +class Heating(models.Model): + """Monthly emissions from heating consumption of a working group""" + + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE) + consumption = models.FloatField(null=False, validators=[MinValueValidator(0.0)]) + timestamp = models.DateField(null=False) + building = models.CharField(null=False, max_length=30) + group_share = models.FloatField( + null=False, validators=[MinValueValidator(0.0), MaxValueValidator(1.0)] + ) + fuel_type_choices = [(x.name, x.value) for x in HeatingFuel] + fuel_type = models.CharField(max_length=20, choices=fuel_type_choices, blank=False) + unit_choices = [(x, x) for x in ["kWh", "l", "kg", "m^3"]] + unit = models.CharField(max_length=20, choices=unit_choices, blank=False) + co2e = models.FloatField() + co2e_cap = models.FloatField() + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("working_group", "timestamp", "fuel_type", "building") + + def __str__(self): + return f"{self.working_group.name}, {self.timestamp}, {self.fuel_type}, {self.building}" \ No newline at end of file diff --git a/backend/src/emissions/models/institution.py b/backend/src/emissions/models/institution.py new file mode 100644 index 00000000..9df396ed --- /dev/null +++ b/backend/src/emissions/models/institution.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Institution Model """ + +from django.db import models +import uuid + + +class Institution(models.Model): + """Research Institution""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=200, null=False, blank=False) + city = models.CharField(max_length=100, null=False, blank=False) + state = models.CharField(max_length=100, null=True) + country = models.CharField(max_length=100, null=False, blank=False) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("name", "city", "country") + + def __str__(self): + return f"{self.name}, {self.city}, {self.country}" \ No newline at end of file diff --git a/backend/src/emissions/models/researchField.py b/backend/src/emissions/models/researchField.py new file mode 100644 index 00000000..e0b2d2ec --- /dev/null +++ b/backend/src/emissions/models/researchField.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Research Field Model """ + +from django.db import models + + +class ResearchField(models.Model): + """Research field""" + + id = models.IntegerField(primary_key=True, null=False, blank=False) + field = models.CharField(max_length=100, null=False, blank=False) + subfield = models.CharField(max_length=100, null=False, blank=False) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("field", "subfield") + + def __str__(self): + return f"{self.field} - {self.subfield}" \ No newline at end of file diff --git a/backend/src/emissions/models/workingGroup.py b/backend/src/emissions/models/workingGroup.py new file mode 100644 index 00000000..6f38fc21 --- /dev/null +++ b/backend/src/emissions/models/workingGroup.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" Working Group Model """ + +from django.db import models +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +import uuid + +from emissions.models import (CustomUser, Institution, ResearchField) + + +class WorkingGroup(models.Model): + """Working group at a research institution""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=True) + name = models.CharField(max_length=200, blank=False) + institution = models.ForeignKey(Institution, on_delete=models.PROTECT, null=True) + representative = models.OneToOneField( + CustomUser, on_delete=models.PROTECT, null=True + ) + n_employees = models.IntegerField(null=True, blank=True) + field = models.ForeignKey(ResearchField, on_delete=models.PROTECT, null=False) + is_public = models.BooleanField(null=False, default=False) + + class Meta: + """Specifies which attributes must be unique together""" + + unique_together = ("name", "institution") + + def clean(self, *args, **kwargs): + """Verify that the representative of the working group is a member of the working group""" + # add custom validation here + if (self.representative.working_group != self) and ( + self.representative.working_group is not None + ): + raise ValidationError( + _( + "This user cannot become the group representative, since they are not a member of this working group." + ), + code="invalid", + ) + super().clean(*args, **kwargs) + + def save(self, *args, **kwargs): + """ + Updates the user who is the representative of the working group + :param args: + :param kwargs: + :return: + """ + self.full_clean() + super(WorkingGroup, self).save(*args, **kwargs) + + def __str__(self): + return f"{self.name}, {self.institution.name}, {self.institution.city}, {self.institution.country}" \ No newline at end of file diff --git a/backend/src/emissions/models/workingGroupJoinRequest.py b/backend/src/emissions/models/workingGroupJoinRequest.py new file mode 100644 index 00000000..a8a059c1 --- /dev/null +++ b/backend/src/emissions/models/workingGroupJoinRequest.py @@ -0,0 +1,17 @@ +from django.db import models +from emissions.models import CustomUser, WorkingGroup +import uuid + + +class WorkingGroupJoinRequest(models.Model): + """Request of a user to join a working group""" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, blank=False, null=False) + working_group = models.ForeignKey(WorkingGroup, on_delete=models.CASCADE, null=False) + timestamp = models.DateTimeField(auto_now_add=True, null=False) + status_choices = [('Pending', 'pending'), ('Approved', 'approved'), ('Declined', 'declined')] + status = models.CharField(max_length=50, choices=status_choices, null=False, blank=False) + + def __str__(self): + return f"{self.user.first_name}, {self.user.last_name}, {self.timestamp}" diff --git a/backend/src/emissions/schema.py b/backend/src/emissions/schema.py index c1251a9f..6341041a 100644 --- a/backend/src/emissions/schema.py +++ b/backend/src/emissions/schema.py @@ -1,441 +1,1304 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""GraphQL endpoints""" + +__email__ = "infopledge4future.org" + +import os +import traceback + import graphene -from django.db.models import Sum, CharField, Value +import datetime as dt +import pandas as pd +from django.core.exceptions import ValidationError +from django.db.models import Sum, F +import numpy as np + from django.db.models.functions import TruncMonth, TruncYear from graphene_django.types import DjangoObjectType, ObjectType from graphql import GraphQLError from graphql_auth.schema import UserQuery, MeQuery from graphql_auth import mutations -from emissions.models import BusinessTrip, User, Electricity, WorkingGroup, Heating, Institution, Commuting, CommutingGroup, BusinessTripGroup -from co2calculator.co2calculator.calculate import calc_co2_electricity, calc_co2_heating, calc_co2_businesstrip, calc_co2_commuting -from graphene_django.filter import DjangoFilterConnectionField -from emissions.graphene_utils import get_fields - -import numpy as np +from emissions.models import ( + BusinessTrip, + CustomUser, + Electricity, + WorkingGroup, + Heating, + Institution, + Commuting, + CommutingGroup, + BusinessTripGroup, + ResearchField, + WorkingGroupJoinRequest +) +from emissions.decorators import representative_required + +from co2calculator.co2calculator.calculate import ( + calc_co2_electricity, + calc_co2_heating, + calc_co2_businesstrip, + calc_co2_commuting, +) +from co2calculator.co2calculator.constants import ElectricityFuel + +from graphql_jwt.decorators import login_required +import warnings + +from emissions.email_client import EmailClient +from django.conf import settings # -------------- GraphQL Types ------------------- WEEKS_PER_MONTH = 4.34524 WEEKS_PER_YEAR = 52.1429 + class UserType(DjangoObjectType): + """GraphQL User Type""" + class Meta: - model = User + """Assign django model""" + + model = CustomUser class WorkingGroupType(DjangoObjectType): + """GraphQL Working Group Type""" + class Meta: + """Assign django model""" + model = WorkingGroup +class WorkingGroupJoinRequestType(DjangoObjectType): + """GraphQL Working Group Type""" + + class Meta: + """Assign django model""" + + model = WorkingGroupJoinRequest + + class InstitutionType(DjangoObjectType): + """GraphQL Institution""" + class Meta: + """Assign django model""" + model = Institution +class ResearchFieldType(DjangoObjectType): + """GraphQL Research Field""" + + class Meta: + """Assign django model""" + + model = ResearchField + + class BusinessTripType(DjangoObjectType): + """GraphQL Business Trip Type""" + class Meta: + """Assign django model""" + model = BusinessTrip + class CommutingType(DjangoObjectType): + """GraphQL Commuting Type""" + class Meta: + """Assign django model""" + model = Commuting + class ElectricityType(DjangoObjectType): + """GraphQL Electricity Type""" + class Meta: + """Assign django model""" + model = Electricity class HeatingType(DjangoObjectType): + """GraphQL Heating Type""" + class Meta: + """Assign django model""" + model = Heating class HeatingAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Heating aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "HeatingAggregated" - filter_fields = ['group_id'] + filter_fields = ["id"] class ElectricityAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Electricity aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "ElectricityAggregated" class BusinessTripAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Business Trips aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "BusinessTripAggregated" class CommutingAggregatedType(ObjectType): - date = graphene.String() - co2e = graphene.Float() - co2e_cap = graphene.Float() + """GraphQL Commuting aggregated by month or year""" + + date = graphene.String(description="Date") + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") class Meta: + """Assign django model""" + name = "CommutingAggregated" + +class TotalEmissionType(ObjectType): + """GraphQL total emissions""" + + working_group_name = graphene.String(description="Name of the working group") + working_group_institution_name = graphene.String( + description="Name of the institution the working group belongs to" + ) + co2e = graphene.Float(description="Total CO2e emissions [tco2e]") + co2e_cap = graphene.Float(description="CO2e emissions per capita [tco2e]") + + class Meta: + """Assign django model""" + + name = "TotalEmission" + + # -------------------- Query types ----------------- # Create a Query type class Query(UserQuery, MeQuery, ObjectType): + """GraphQL Queries""" + businesstrips = graphene.List(BusinessTripType) electricities = graphene.List(ElectricityType) heatings = graphene.List(HeatingType) commutings = graphene.List(CommutingType) - working_groups = graphene.List(WorkingGroupType) + workinggroups = graphene.List(WorkingGroupType) + researchfields = graphene.List(ResearchFieldType) + institutions = graphene.List(InstitutionType) + workinggroup_users = graphene.List(UserType) + join_requests = graphene.List(WorkingGroupJoinRequestType) # Aggregated data - heating_aggregated = graphene.List(HeatingAggregatedType, - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - electricity_aggregated = graphene.List(ElectricityAggregatedType, - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - businesstrip_aggregated = graphene.List(BusinessTripAggregatedType, - username=graphene.String(), - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - commuting_aggregated = graphene.List(CommutingAggregatedType, - username=graphene.String(), - group_id=graphene.UUID(), - inst_id=graphene.UUID(), - time_interval=graphene.String()) - + heating_aggregated = graphene.List( + HeatingAggregatedType, + level=graphene.String( + description="Aggregation level: group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + electricity_aggregated = graphene.List( + ElectricityAggregatedType, + level=graphene.String( + description="Aggregation level: group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + businesstrip_aggregated = graphene.List( + BusinessTripAggregatedType, + level=graphene.String( + description="Aggregation level: personal, group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + commuting_aggregated = graphene.List( + CommutingAggregatedType, + level=graphene.String( + description="Aggregation level: personal, group or institution. Default: group" + ), + time_interval=graphene.String( + description="Time interval for aggregation (month or year)" + ), + ) + total_emission = graphene.List( + TotalEmissionType, + start=graphene.Date( + description="Start date for calculation of total emissions" + ), + end=graphene.Date(description="End date for calculation of total emissions"), + level=graphene.String( + description="Aggregate by 'group' or 'institution'. Default: 'group')" + ), + ) + + @login_required + def resolve_workinggroups(self, info, **kwargs): + """Yields all working group objects""" + return WorkingGroup.objects.filter(is_public=True) + + @login_required + @representative_required + def resolve_workinggroup_users(self, info, **kawrgs): + """Returns the users of a certain working group.""" + id = info.context.user.working_group.id + return CustomUser.objects.filter(working_group__id=id) + + @login_required + @representative_required + def resolve_join_requests(self, info, **kwargs): + """Yields all institution objects""" + id = info.context.user.working_group.id + return WorkingGroupJoinRequest.objects.filter(working_group__id=id) + + def resolve_institutions(self, info, **kwargs): + """Yields all institution objects""" + return Institution.objects.all() + + def resolve_researchfields(self, info, **kwargs): + """Yields all reseach field objects""" + return ResearchField.objects.all() + + @login_required def resolve_businesstrips(self, info, **kwargs): - """ Yields all heating consumption objects""" - return BusinessTrip.objects.all() + """Yields all heating consumption objects""" + user = info.context.user + return BusinessTrip.objects.all(user__id=user.id) + @login_required def resolve_electricities(self, info, **kwargs): - """ Yields all heating consumption objects""" - return Electricity.objects.all() + """Yields all heating consumption objects""" + user = info.context.user + return Electricity.objects.all(working_group__id=user.working_group.id) + @login_required def resolve_heatings(self, info, **kwargs): - """ Yields all heating consumption objects""" - return Heating.objects.all() - - def resolve_commutingss(self, info, **kwargs): - """ Yields all heating consumption objects""" - return Commuting.objects.all() - - def resolve_working_groups(self, info, **kwargs): - """ Yields all working group objects """ - return WorkingGroup.objects.all() - - def resolve_heating_aggregated(self, info, group_id=None, inst_id=None, time_interval="month", **kwargs): + """Yields all heating consumption objects""" + user = info.context.user + return Heating.objects.all(working_group__id=user.working_group.id) + + @login_required + def resolve_commutings(self, info, **kwargs): + """Yields all heating consumption objects""" + user = info.context.user + return Commuting.objects.all(user__id=user.id) + + @login_required + def resolve_heating_aggregated( + self, info, level="group", time_interval="month", **kwargs + ): """ - Yields monthly co2e emissions (per capita) of heating consumption - - for a group (if group_id is given), - - for an institution (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + Yields monthly co2e emissions (per capita) of heating consumption, for the user, their group or their institution + param: level: Aggregation level: personal, group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ + #if not info.context.user.is_authenticated: + # raise GraphQLError("User is not authenticated.") + user = info.context.user + + if user.working_group is None: + raise GraphQLError("No heating data available, since user is not assigned to any working group yet.") + # Get relevant data entries - if group_id: - entries = Heating.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = Heating.objects.filter(working_group__institution__inst_id=inst_id) + if level == "personal": + entries = Heating.objects.filter(working_group__id=user.working_group.id) + # Use the average co2e emissions per capital as total emissons for one person + metrics = {"co2e": Sum("co2e_cap"), "co2e_cap": Sum("co2e_cap")} + elif level == "group": + entries = Heating.objects.filter(working_group__id=user.working_group.id) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + elif level == "institution": + entries = Heating.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} else: - entries = Heating.objects.all() + raise GraphQLError(f"Invalid value for parameter 'level': {level}") - metrics = { - 'co2e': Sum('co2e'), - 'co2e_cap': Sum('co2e_cap') - } - - # Annotate based on groupby - #if groupby == "total": - # return not entries.annotate(date=Value('total', output_field=CharField()))\ - # .values('date')\ - # .annotate(co2e=Sum("co2e"))\ - # .order_by('date') - # #.annotate(co2e_cap=Sum("co2e_cap"))\ - # #.order_by('date') if time_interval == "month": - return entries.annotate(date=TruncMonth('timestamp')).values('date').annotate(**metrics).order_by('date') + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) elif time_interval == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) else: raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") - def resolve_electricity_aggregated(self, info, group_id=None, inst_id=None, time_interval="month", **kwargs): + @login_required + def resolve_electricity_aggregated( + self, info, level="group", time_interval="month", **kwargs + ): """ - Yields monthly co2e emissions of electricity consumption - - for a group (if group_id is given), - - for an institutions (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + Yields monthly co2e emissions (per capita) of electricity consumption, for the user, their group or their institution + param: level: Aggregation level: group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ - if group_id: - entries = Electricity.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = Electricity.objects.filter(working_group__institution__inst_id=inst_id) + user = info.context.user + if user.working_group is None: + raise GraphQLError("No heating data available, since user is not assigned to any working group yet.") + + # Get relevant data entries + if level == "personal": + entries = Electricity.objects.filter(working_group__id=user.working_group.id) + # Use the average co2e emissions per capital as total emissons for one person + metrics = {"co2e": Sum("co2e_cap"), "co2e_cap": Sum("co2e_cap")} + elif level == "group": + entries = Electricity.objects.filter(working_group__id=user.working_group.id) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + elif level == "institution": + entries = Electricity.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} else: - entries = Electricity.objects.all() - metrics = { - 'co2e': Sum('co2e'), - 'co2e_cap': Sum('co2e_cap') - } + raise GraphQLError(f"Invalid value for parameter 'level': {level}") + if time_interval == "month": - return entries.annotate(date=TruncMonth('timestamp')).values('date').annotate(**metrics).order_by('date') + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) elif time_interval == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) else: raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") - def resolve_businesstrip_aggregated(self, info, username=None, group_id=None, inst_id=None, time_interval="month", **kwargs): + @login_required + def resolve_businesstrip_aggregated( + self, + info, + level="group", + time_interval="month", + **kwargs, + ): """ Yields monthly co2e emissions of businesstrips - - for a user (if username is given), + - for a user, - for a group (if group_id is given), - for an institution (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + param: level: Aggregation level: personal, group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ - if username: - entries = BusinessTrip.objects.filter(user__username=username) - elif group_id: - entries = BusinessTripGroup.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = BusinessTripGroup.objects.filter(working_group__institution__inst_id=inst_id) + user = info.context.user + # Get relevant data entries + if level == "personal": + entries = BusinessTrip.objects.filter(user__id=user.id) + entries = entries.annotate(co2e_cap=F("co2e")) + elif level == "group": + entries = BusinessTripGroup.objects.filter( + working_group__id=user.working_group.id + ) + elif level == "institution": + entries = BusinessTripGroup.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) else: - entries = BusinessTrip.objects.all() + raise GraphQLError(f"Invalid value for parameter 'level': {level}") - metrics = { - 'co2e': Sum('co2e'), - } - if not username: - metrics['co2e_cap'] = Sum('co2e_cap') - - if time_interval.lower() == "month": - return entries.annotate(date=TruncMonth('timestamp')).values('date').annotate(**metrics).order_by('date') - elif time_interval.lower() == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') - else: - raise GraphQLError(f"'{time_interval}' is not a valid option for parameter 'time_interval'.") + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + if time_interval == "month": + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) + elif time_interval == "year": + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) + else: + raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") - def resolve_commuting_aggregated(self, info, username=None, group_id=None, inst_id=None, time_interval="month", **kwargs): + @login_required + def resolve_commuting_aggregated( + self, + info, + level="group", + time_interval="month", + **kwargs, + ): """ Yields monthly co2e emissions of businesstrips - - for a user (if username is given), + - for a user, - for a group (if group_id is given), - for an institution (if inst_id is given) - param: username: username of user model (str) - param: group_id: UUID id of WorkingGroup model (str) - param: inst_id: UUID id of Institute model (str) + param: level: Aggregation level: group or institution. Default: group param: time_interval: Aggregate co2e per "month" or "year" """ - metrics = { - 'co2e': Sum('co2e'), - 'co2e_cap': Sum('co2e_cap'), - } - if username: - entries = Commuting.objects.filter(user__username=username) - metrics.pop("co2e_cap") - elif group_id: - entries = CommutingGroup.objects.filter(working_group__group_id=group_id) - elif inst_id: - entries = CommutingGroup.objects.filter(working_group__institution__inst_id=inst_id) + user = info.context.user + # Get relevant data entries + if level == "personal": + entries = Commuting.objects.filter(user__id=user.id) + entries = entries.annotate(co2e_cap=F("co2e")) + elif level == "group": + entries = CommutingGroup.objects.filter( + working_group__id=user.working_group.id + ) + elif level == "institution": + entries = CommutingGroup.objects.filter( + working_group__institution__id=user.working_group.institution.id + ) else: - entries = Commuting.objects.all() - - if time_interval.lower() == "month": - return entries.annotate(date=TruncMonth('timestamp'))\ - .values('date')\ - .annotate(**metrics)\ - .order_by('date') - elif time_interval.lower() == "year": - return entries.annotate(date=TruncYear('timestamp'))\ - .values('date')\ - .annotate(**metrics) \ - .order_by('date') + raise GraphQLError(f"Invalid value for parameter 'level': {level}") + + metrics = {"co2e": Sum("co2e"), "co2e_cap": Sum("co2e_cap")} + + if time_interval == "month": + return ( + entries.annotate(date=TruncMonth("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) + elif time_interval == "year": + return ( + entries.annotate(date=TruncYear("timestamp")) + .values("date") + .annotate(**metrics) + .order_by("date") + ) else: - raise GraphQLError(f"'{time_interval}' is not a valid option for parameter 'time_interval'.") + raise GraphQLError(f"Invalid option {time_interval} for 'time_interval'.") + def resolve_total_emission( + self, info, start=None, end=None, level="group", **kwargs + ): + """ + Yields total emissions on monthly or yearly basis + param: start: Start date for calculation of total emissions. If none is given, the last 12 months will be used. + param: end: end date for calculation of total emission If none is given, the last 12 months will be used. + """ + metrics = { + "co2e": Sum("co2e"), + "co2e_cap": Sum("co2e_cap"), + } + if not end and not start: + end = dt.datetime( + day=1, + month=dt.datetime.today().month - 1, + year=dt.datetime.today().year, + ) + start = dt.datetime( + day=1, + month=dt.datetime.today().month, + year=dt.datetime.today().year - 1, + ) + + if level == "group": + aggregate_on = ["working_group__name", "working_group__institution__name"] + elif level == "institution": + aggregate_on = ["working_group__institution__name"] + else: + raise GraphQLError(f"Invalid value for parameter 'level': {level}") + + heating_emissions = ( + Heating.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + heating_df = pd.DataFrame(list(heating_emissions)) + + electricity_emissions = ( + Electricity.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + electricity_df = pd.DataFrame(list(electricity_emissions)) + + businesstrips_emissions = ( + BusinessTripGroup.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + businesstrips_df = pd.DataFrame(list(businesstrips_emissions)) + + commuting_emissions = ( + CommutingGroup.objects.filter(timestamp__gte=start, timestamp__lte=end) + .values(*aggregate_on) + .annotate(**metrics) + ) + commuting_df = pd.DataFrame(list(commuting_emissions)) + + total_emissions = ( + pd.concat([heating_df, electricity_df, commuting_df, businesstrips_df]) + .groupby(aggregate_on) + .sum() + ) + total_emissions.reset_index(inplace=True) + + emissions_for_groups = [] + if level == "group": + for _, row in total_emissions.iterrows(): + section = TotalEmissionType( + working_group_name=row["working_group__name"], + working_group_institution_name=row[ + "working_group__institution__name" + ], + co2e=row["co2e"], + co2e_cap=row["co2e_cap"], + ) + emissions_for_groups.append(section) + elif level == "institution": + for _, row in total_emissions.iterrows(): + section = TotalEmissionType( + working_group_name=None, + working_group_institution_name=row[ + "working_group__institution__name" + ], + co2e=row["co2e"], + co2e_cap=row["co2e_cap"], + ) + emissions_for_groups.append(section) + return emissions_for_groups # -------------- Input Object Types -------------------------- +class JoinRequestInput(graphene.InputObjectType): + """GraphQL Input type for sending request to join a working group""" + + workinggroup_id = graphene.String(reqired=True, description="ID of the working group") + class CommutingInput(graphene.InputObjectType): - id = graphene.ID() - username = graphene.String(required=True) - from_timestamp = graphene.Date(required=True) - to_timestamp = graphene.Date(required=True) - transportation_mode = graphene.String(required=True) - workweeks = graphene.Int() - distance = graphene.Float() - size = graphene.String() - fuel_type = graphene.String() - occupancy = graphene.Float() - passengers = graphene.Int() + """GraphQL Input type for commuting""" + + from_timestamp = graphene.Date(required=True, description="Start date") + to_timestamp = graphene.Date(required=True, description="End date") + transportation_mode = graphene.String( + required=True, description="Transportation mode" + ) + workweeks = graphene.Int(description="Number of work weeks") + distance = graphene.Float(description="Distance [meter]") + size = graphene.String(description="Size of the vehicle") + fuel_type = graphene.String(description="Fuel type of the vehicle") + occupancy = graphene.Float(description="Occupancy of the vehicle") + passengers = graphene.Int(description="Number of passengers in the vehicle") + + +class PlanTripInput(graphene.InputObjectType): + """GraphQL Input type for the trip planner""" + + transportation_mode = graphene.String( + required=True, description="Transportation mode" + ) + start_address = graphene.String(description="Start address") + start_city = graphene.String(description="Start city") + start_country = graphene.String(description="Start country") + destination_address = graphene.String(description="Destination address") + destination_city = graphene.String(description="Destination city") + destination_country = graphene.String(description="Destination country") + distance = graphene.Float(description="Distance [meter]") + size = graphene.String(description="Size of the vehicle") + fuel_type = graphene.String(description="Fuel type of the vehicle") + occupancy = graphene.Float(description="Occupancy") + seating_class = graphene.String(description="Seating class in plane") + passengers = graphene.Int(description="Number of passengers") + roundtrip = graphene.Boolean(description="Roundtrip [True/False]") class BusinessTripInput(graphene.InputObjectType): - id = graphene.ID() - username = graphene.String(required=True) - group_id = graphene.UUID(required=True) - timestamp = graphene.Date(required=True) - transportation_mode = graphene.String(required=True) - start = graphene.String() - destination = graphene.String() - distance = graphene.Float() - size = graphene.String() - fuel_type = graphene.String() - occupancy = graphene.Float() - seating_class = graphene.Int() - passengers = graphene.Int() - roundtrip = graphene.Boolean() + """GraphQL Input type for Business trips""" + + timestamp = graphene.Date(required=True, description="Date") + transportation_mode = graphene.String( + required=True, description="Transportation mode" + ) + start = graphene.String(description="Start address") + destination = graphene.String(description="Destination address") + distance = graphene.Float(description="Distance [meter]") + size = graphene.String(description="Size of the vehicle") + fuel_type = graphene.String(description="Fuel type of the vehicle") + occupancy = graphene.Float(description="Occupancy") + seating_class = graphene.String(description="Seating class in plane") + passengers = graphene.Int(description="Number of passengers") + roundtrip = graphene.Boolean(description="Roundtrip [True/False]") class ElectricityInput(graphene.InputObjectType): - id = graphene.ID() - group_id = graphene.UUID() - timestamp = graphene.Date(required=True) - consumption = graphene.Float() - fuel_type = graphene.String(required=True) - building = graphene.String(required=True) - group_share = graphene.Float(required=True) + """GraphQL Input type for electricity""" + + timestamp = graphene.Date(required=True, description="Date") + consumption = graphene.Float(description="Consumption") + fuel_type = graphene.String(required=True, description="Fuel type") + building = graphene.String( + required=True, description="Number of Building if there are several ones" + ) + group_share = graphene.Float( + required=True, description="Share of the building beloning to the working group" + ) class HeatingInput(graphene.InputObjectType): - id = graphene.ID() - group_id = graphene.UUID() - timestamp = graphene.Date(required=True) - consumption = graphene.Float(required=True) - unit = graphene.String(required=True) - fuel_type = graphene.String(required=True) - building = graphene.String(required=True) - group_share = graphene.Float(required=True) - - -class UserInput(graphene.InputObjectType): - id = graphene.ID() - workinggroupid = graphene.Int() - email = graphene.String() - username = graphene.String() - first_name = graphene.String() - last_name = graphene.String() - is_representative = graphene.Boolean() + """GraphQL Input type for heating""" + + timestamp = graphene.Date(required=True, description="Date") + consumption = graphene.Float(required=True, description="Consumption") + unit = graphene.String(required=True, description="Unit of fuel type") + fuel_type = graphene.String(required=True, description="Fuel type") + building = graphene.String( + required=True, description="Number of Building if there are several ones" + ) + group_share = graphene.Float( + required=True, description="Share of the building beloning to the working group" + ) + + +class CreateWorkingGroupInput(graphene.InputObjectType): + """GraphQL Input type for creating a new working group""" + + name = graphene.String(reqired=True, description="Name of the working group") + institution_id = graphene.String( + required=True, description="UUID of institution the working group belongs to" + ) + research_field_id = graphene.Int( + required=True, description="ID of Research field of working group" + ) + n_employees = graphene.Int( + required=True, description="Number of employees of working group" + ) + is_public = graphene.Boolean(required=True, + description="If true, the group will be publicly visible.") + +class DeleteWorkingGroupInput(graphene.InputObjectType): + """GraphQL Input type for deleting an existing working group""" + + id = graphene.String(required=True, description="ID of the working group") + + +class SetWorkingGroupInput(graphene.InputObjectType): + """GraphQL Input type for setting working group""" + + id = graphene.String(reqired=True, description="ID of the working group") + +class RemoveUserFromWorkingGroupInput(graphene.InputObjectType): + """GraphQL input type for removing a user from a working group""" + + user_id = graphene.String(required=True, description="ID of the user that should be removed") + + +class AnswerJoinRequestInput(graphene.InputObjectType): + """GraphQL Input type for setting working group""" + + request_id = graphene.String(required=True, description="ID of join request") + approve = graphene.Boolean(reqired=True, description="Approve (true) or decline (false) join request") + +class AddUserToWorkingGroupInput(graphene.InputObjectType): + """GraphQL input type for adding a user to a working group""" + + user_email = graphene.String(required=True, description="eMail of the user that should be added to the working group") # --------------- Mutations ------------------------------------ class AuthMutation(graphene.ObjectType): - register = mutations.Register.Field() - verify_account = mutations.VerifyAccount.Field() - token_auth = mutations.ObtainJSONWebToken.Field() - update_account = mutations.UpdateAccount.Field() - resend_activation_email = mutations.ResendActivationEmail.Field() - send_password_reset_email = mutations.SendPasswordResetEmail.Field() - password_reset = mutations.PasswordReset.Field() - password_change = mutations.PasswordChange.Field() + """Authentication mutations""" + + register = mutations.Register.Field() + verify_account = mutations.VerifyAccount.Field() + resend_activation_email = mutations.ResendActivationEmail.Field() + send_password_reset_email = mutations.SendPasswordResetEmail.Field() + password_reset = mutations.PasswordReset.Field() + password_set = mutations.PasswordSet.Field() + archive_account = mutations.ArchiveAccount.Field() + delete_account = mutations.DeleteAccount.Field() + password_change = mutations.PasswordChange.Field() + update_account = mutations.UpdateAccount.Field() + send_secondary_email_activation = mutations.SendSecondaryEmailActivation.Field() + verify_secondary_email = mutations.VerifySecondaryEmail.Field() + swap_emails = mutations.SwapEmails.Field() + remove_secondary_email = mutations.RemoveSecondaryEmail.Field() + + token_auth = mutations.ObtainJSONWebToken.Field() + verify_token = mutations.VerifyToken.Field() + refresh_token = mutations.RefreshToken.Field() + revoke_token = mutations.RevokeToken.Field() + + +class CreateWorkingGroup(graphene.Mutation): + """Mutation to create a new working group""" + + class Arguments: + """Assign input type""" + input = CreateWorkingGroupInput() + + success = graphene.Boolean() + workinggroup = graphene.Field(WorkingGroupType) + + @staticmethod + @login_required + def mutate(root, info, input=None): + """Process incoming data""" + user = info.context.user + success = True + + institution_found = Institution.objects.filter( + id=input.institution_id + ) + if len(institution_found) == 0: + raise GraphQLError("Institution not found.") + else: + institution = institution_found[0] + + field_found = ResearchField.objects.filter(id=input.research_field_id) + if len(field_found) == 0: + raise GraphQLError("Research field is invalid.") + else: + research_field = field_found[0] + + # Check if working group already exists + exists = WorkingGroup.objects.filter(name=input.name, institution=institution) + if len(exists) > 0: + raise GraphQLError( + "This working group cannot be created, because it already exists." + ) + elif user.is_representative is True: + raise GraphQLError( + "This user cannot create a new working group, since they are already the representative of another working group." + ) + new_workinggroup = WorkingGroup( + name=input.name, + institution=institution, + representative=user, + field=research_field, + n_employees=input.n_employees, + is_public=input.is_public + ) + new_workinggroup.save() + + user.working_group = new_workinggroup + user.is_representative = True + user.save() + + return CreateWorkingGroup(success=success, workinggroup=new_workinggroup) + +class LeaveWorkingGroup(graphene.Mutation): + """Mutation to create a new working group""" + + success = graphene.Boolean() + + @staticmethod + @login_required + def mutate(root, info): + user = info.context.user + + if user.is_representative is True: + raise GraphQLError( + "Users that are representatives can not leave their working groups. Please delete the working group instead." + ) + + try: + setattr(user, "working_group", None) + user.save() + return LeaveWorkingGroup(success=True) + except ValidationError as e: + return LeaveWorkingGroup(success=False, errors=e) +class DeleteWorkingGroup(graphene.Mutation): + """Mutatino to delete an existing working group""" + + class Arguments: + """Assign input type""" + input = DeleteWorkingGroupInput() + + success = graphene.Boolean() + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input=None): + + user = info.context.user + + working_groups = WorkingGroup.objects.filter( + id=input.id + ) + + if len(working_groups) == 0: + raise GraphQLError("Working group not found.") + if len(working_groups) > 1: + raise GraphQLError("More then one working group found, invalid ID") + + group_to_delete = working_groups[0] + + if info.context.user.id != group_to_delete.representative.id: + raise GraphQLError("You are not the representative of the specified working group. Unable to delete") + + try: + setattr(user, "is_representative", False) + user.save() + working_groups[0].delete() + return DeleteWorkingGroup(success=True) + except ValidationError as e: + return DeleteWorkingGroup(success=False, errors=e) + + + +class SetWorkingGroup(graphene.Mutation): + """GraphQL mutation to set working group of user""" + + class Arguments: + """Assign input type""" + + input = SetWorkingGroupInput() + + success = graphene.Boolean() + user = graphene.Field(UserType) + + @staticmethod + @login_required + def mutate(root, info, input=None): + """Process incoming data""" + user = info.context.user + success = True + + # Search matching working groups + matching_working_groups = WorkingGroup.objects.filter( + id=input.id + ) + if len(matching_working_groups) == 0: + raise GraphQLError("Working group not found.") + else: + working_group = matching_working_groups[0] + + setattr(user, "working_group", working_group) + user.save() + + try: + user.full_clean() + user.save() + return SetWorkingGroup(user=user, success=success) + except ValidationError as e: + return SetWorkingGroup(user=user, success=success, errors=e) + + +class RemoveUserFromWorkingGroup(graphene.Mutation): + """GraphQL mutation to remove a user from a working group""" + + class Arguments: + """Input structure defined by input type""" + + input = RemoveUserFromWorkingGroupInput() + + success = graphene.Boolean() + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input): + """Process incoming data""" + user = info.context.user + + user_subset = CustomUser.objects.filter(id=input.user_id) + + if len(user_subset) == 0: + raise GraphQLError( + "The user you were trying to remove was not found. Please contact your administrator." + ) + else: + user_to_remove = user_subset[0] + + if user_to_remove.is_representative is True: + raise GraphQLError( + "Users that are representatives of the working group can not be removed from the groups." + ) + + if user_to_remove.working_group != user.working_group: + raise GraphQLError( + "The user you are trying to remove is not part of your working group!" + ) + + try: + setattr(user_to_remove, "working_group", None) + user_to_remove.save() + return RemoveUserFromWorkingGroup(success=True) + except ValidationError as e: + return RemoveUserFromWorkingGroup(success=False, errors=e) + + +class AddUserToWorkingGroup(graphene.Mutation): + """GraphQL mutaiton to add a user to a working group""" + + class Arguments: + """Input strucutred defined by input type""" + + input = AddUserToWorkingGroupInput() + + success= graphene.Boolean() + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input): + """Process incoming data""" + + user = info.context.user + + working_group_set = WorkingGroup.objects.filter(id=user.working_group.id) + + if len(working_group_set) == 0: + raise GraphQLError("You are not part of a working group. You can only add users to your own working group.") + else: + working_group = working_group_set[0] + if info.context.user.id != working_group.representative.id: + raise GraphQLError("You are not the representative of the specified working group. Unable to delete") + + user_to_add_query_set = CustomUser.objects.filter(email=input.user_email) + + if len(user_to_add_query_set) > 1: + raise GraphQLError(f"Invalid email address input. Multiple users found.") # this should never happen as email addresses should be unique in the database + elif len(user_to_add_query_set) == 0: + raise GraphQLError(f"The user that you are trying to add is not registered.") + else: + user_to_add = user_to_add_query_set[0] + if user_to_add.working_group != None: + raise GraphQLError(f"The user you are trying to add already has a working group assigned.") + + # assign working group to user + try: + setattr(user_to_add, "working_group", working_group) + user_to_add.save() + return AddUserToWorkingGroup(success=True) + except ValidationError as e: + return AddUserToWorkingGroup(success=False, errors=e) + + +class AnswerJoinRequest(graphene.Mutation): + """GraphQL mutation to set working group of user""" + + class Arguments: + """Assign input type""" + + input = AnswerJoinRequestInput() + + success = graphene.Boolean() + requesting_user = graphene.Field(UserType) + + @staticmethod + @login_required + @representative_required + def mutate(root, info, input: AnswerJoinRequestInput = None): + """Process incoming data""" + success = True + user = info.context.user + + # Search for join request + matching_join_request = WorkingGroupJoinRequest.objects.filter(id=input.request_id) + if len(matching_join_request) == 0: + raise GraphQLError("Join request not found.") + else: + join_request = matching_join_request[0] + + # Check if the current user is the representative of the working group + if not user.working_group == join_request.working_group: + raise GraphQLError(f"You are not authorized to answer this join request, because you are " + f"not the representative of the {join_request.working_group.name}.") + + if not input.approve: + requesting_user = join_request.user + join_request.status = 'Declined' + join_request.save() + return AnswerJoinRequest(success=True, requesting_user=requesting_user) + elif input.approve: + requesting_user = join_request.user + setattr(join_request.user, "working_group", join_request.working_group) + requesting_user.save() + join_request.status = 'Approved' + join_request.save() + + try: + requesting_user.full_clean() + requesting_user.save() + return AnswerJoinRequest(success=success, requesting_user=requesting_user) + except ValidationError as e: + return SetWorkingGroup(success=success, requesting_user=None, errors=e) + + +class RequestJoinWorkingGroup(graphene.Mutation): + """GraphQL mutation to request to join a working group""" + + class Arguments: + """Assign input type""" + + input = JoinRequestInput() + + success = graphene.Boolean() + join_request = graphene.Field(WorkingGroupJoinRequestType) + + @staticmethod + @login_required + def mutate(root, info, input=None): + """Process incoming data""" + user = info.context.user + success = True + + # Search matching working groups + matching_working_groups = WorkingGroup.objects.filter( + id=input.workinggroup_id + ) + if len(matching_working_groups) == 0: + raise GraphQLError("Working group not found.") + else: + working_group = matching_working_groups[0] + + # Create entry in workinggroup join requests tabel + new_request = WorkingGroupJoinRequest(user=user, + working_group=working_group, + status='Pending') + new_request.save() + + # Send email to group representative + representative = working_group.representative + values = {'representative_first_name': representative.first_name, + 'representative_last_name': representative.last_name, + 'user_first_name': user.first_name, + 'user_last_name': user.last_name, + 'working_group_name': working_group.name, + 'path': os.getenv("PUBLIC_URL", "https://localhost") + "/working-group-details" + } + TEMPLATE_DIR = settings.TEMPLATES[0]['DIRS'][0] + email_client = EmailClient(template_dir=TEMPLATE_DIR) + text, html = email_client.get_template_email('join_request', values) + subject = email_client.get_template_subject('join_request', values) + email_client.send_email(subject, + html, + from_email="no-reply@pledge4future.org", + to_email=representative.email) + + + return RequestJoinWorkingGroup(success=success, join_request=new_request) class CreateElectricity(graphene.Mutation): + """GraphQL mutation for electricity""" + class Arguments: + """Assign input type""" + input = ElectricityInput(required=True) - ok = graphene.Boolean() + success = graphene.Boolean() electricity = graphene.Field(ElectricityType) @staticmethod + @login_required + @representative_required def mutate(root, info, input=None): - ok = True - matches = WorkingGroup.objects.filter(group_id=input.group_id) - if len(matches) == 0: - raise GraphQLError(f"Permission denied: Could add electricity data, because user '{input.username}' " - f"is not a group representative.") - else: - working_group = matches[0] + """Process incoming data""" + user = info.context.user + success = True + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") # Calculate co2 - co2e = calc_co2_electricity(input.consumption, input.fuel_type, input.group_share) - new_electricity = Electricity(working_group=working_group, - timestamp=input.timestamp, - consumption=input.consumption, - fuel_type=input.fuel_type, - group_share=input.group_share, - building=input.building, - co2e=round(co2e, 1)) + co2e = calc_co2_electricity( + input.consumption, + input.fuel_type, + input.group_share, + ) + + co2e_cap = co2e / user.working_group.n_employees + + # Store in database + new_electricity = Electricity( + working_group=user.working_group, + timestamp=input.timestamp, + consumption=input.consumption, + fuel_type=ElectricityFuel[input.fuel_type.upper()].name, + group_share=input.group_share, + building=input.building, + co2e=round(co2e, 1), + co2e_cap=round(co2e_cap, 1), + ) new_electricity.save() - return CreateElectricity(ok=ok, electricity=new_electricity) + return CreateElectricity(success=success, electricity=new_electricity) class CreateHeating(graphene.Mutation): + """GraphQL mutation for heating""" + class Arguments: + """Assign input type""" + input = HeatingInput(required=True) - ok = graphene.Boolean() + success = graphene.Boolean() heating = graphene.Field(HeatingType) @staticmethod + @login_required + @representative_required def mutate(root, info, input=None): - ok = True - matches = WorkingGroup.objects.filter(group_id=input.group_id) - if len(matches) == 0: - raise GraphQLError(f"Permission denied: Could add electricity data, because user '{input.username}' " - f"is not a group representative.") - else: - working_group = matches[0] - - # calculate co2 - co2e = calc_co2_heating(input.consumption, input.unit, input.fuel_type, input.group_share) - new_heating = Heating(working_group=working_group, - timestamp=input.timestamp, - consumption=input.consumption, - fuel_type=input.fuel_type, - building=input.building, - group_share=input.group_share, - co2e=round(co2e, 1)) + """Process incoming data""" + success = True + user = info.context.user + + # Calculate co2e + co2e = calc_co2_heating( + consumption=input.consumption, + unit=input.unit.lower().replace(" ", "_"), + fuel_type=input.fuel_type.lower().replace(" ", "_"), + area_share=input.group_share, + ) + co2e_cap = co2e / user.working_group.n_employees + + # Store in database + new_heating = Heating( + working_group=user.working_group, + timestamp=input.timestamp, + consumption=input.consumption, + fuel_type=input.fuel_type.upper().replace(" ", "_"), + unit=input.unit.lower().replace(" ", "_"), + building=input.building, + group_share=input.group_share, + co2e=round(co2e, 1), + co2e_cap=round(co2e_cap, 1), + ) new_heating.save() - return CreateHeating(ok=ok, heating=new_heating) + return CreateHeating(success=success, heating=new_heating) + + +class PlanTrip(graphene.Mutation): + """GraphQL mutation for business trips""" + + class Arguments: + """Assign input type""" + + input = PlanTripInput(required=True) + + success = graphene.Boolean() + co2e = graphene.Float() + message = graphene.String() + + @staticmethod + def mutate(root, info, input=None): + """Process incoming data""" + success = True + message = "success" + if input.seating_class: + input.seating_class = input.seating_class.lower().replace(" ", "_") + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") + if input.size: + input.size = input.size.lower().replace(" ", "_") + if input.transportation_mode: + input.transportation_mode = input.transportation_mode.lower().replace( + " ", "_" + ) + # CO2e calculation + start_dict = {"address": input.start_address, + "locality": input.start_city, + "country": input.start_country} + destination_dict = {"address": input.destination_address, + "locality": input.destination_city, + "country": input.destination_country} + + try: + co2e, distance, range_category, _ = calc_co2_businesstrip( + start=start_dict, + destination=destination_dict, + distance=input.distance, + transportation_mode=input.transportation_mode, + size=input.size, + fuel_type=input.fuel_type, + occupancy=input.occupancy, + seating=input.seating_class, + passengers=input.passengers, + roundtrip=input.roundtrip, + ) + print(co2e, distance, range_category, _) + except Exception as e: + traceback.print_exc() + return PlanTrip(success=False, message=str(e), co2e=None) + except RuntimeWarning as e: + message = e + return PlanTrip(success=success, message=message, co2e=co2e) + class CreateBusinessTrip(graphene.Mutation): + """GraphQL mutation for business trips""" + class Arguments: + """Assign input type""" + input = BusinessTripInput(required=True) - ok = graphene.Boolean() - #businesstrip = graphene.Field(BusinessTripType) + success = graphene.Boolean() + businesstrip = graphene.Field(BusinessTripType) @staticmethod + @login_required def mutate(root, info, input=None): - ok = True - user = User.objects.filter(username=input.username) - if len(user) == 0: - print("{} user not found".format(input.username)) - + """Process incoming data""" + success = True + user = info.context.user + if input.seating_class: + input.seating_class = input.seating_class.lower().replace(" ", "_") + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") + if input.size: + input.size = input.size.lower().replace(" ", "_") + if input.transportation_mode: + input.transportation_mode = input.transportation_mode.lower().replace( + " ", "_" + ) + # CO2e calculation co2e, distance, range_category, _ = calc_co2_businesstrip( start=input.start, destination=input.destination, @@ -446,88 +1309,123 @@ def mutate(root, info, input=None): occupancy=input.occupancy, seating=input.seating_class, passengers=input.passengers, - roundtrip=input.roundtrip) - businesstrip_instance = BusinessTrip(timestamp=input.timestamp, - distance=distance, - range_category=range_category, - transportation_mode=input.transportation_mode, - co2e=co2e, - user=user[0], - working_group=user[0].working_group) + roundtrip=input.roundtrip, + ) + # Write data to database + businesstrip_instance = BusinessTrip( + timestamp=input.timestamp, + distance=distance, + range_category=range_category, + transportation_mode=input.transportation_mode, + co2e=co2e, + user=user, + working_group=user.working_group, + ) businesstrip_instance.save() - return CreateBusinessTrip(ok=ok) + return CreateBusinessTrip(success=success, businesstrip=businesstrip_instance) class CreateCommuting(graphene.Mutation): + """GraphQL mutation for commuting""" + class Arguments: + """Assign input type""" + input = CommutingInput(required=True) - ok = graphene.Boolean() - #commute = graphene.Field(CommutingType) + success = graphene.Boolean() + # commute = graphene.Field(CommutingType) @staticmethod + @login_required def mutate(root, info, input=None): - ok = True - user = User.objects.filter(username=input.username) - if len(user) == 0: - raise GraphQLError(f"{input.username} user not found") - user = user[0] + """Process incoming data""" + success = True + user = info.context.user if input.workweeks is None: input.workweeks = WEEKS_PER_YEAR - - # calculate co2 - weekly_co2e = calc_co2_commuting(transportation_mode=input.transportation_mode, - weekly_distance=input.distance, - size=input.size, - fuel_type=input.fuel_type, - occupancy=input.occupancy, - passengers=input.passengers - ) + if input.transportation_mode: + input.transportation_mode = input.transportation_mode.lower().replace( + " ", "_" + ) + if input.fuel_type: + input.fuel_type = input.fuel_type.lower().replace(" ", "_") + if input.size: + input.size = input.size.lower().replace(" ", "_") + # Calculate co2 + weekly_co2e = calc_co2_commuting( + transportation_mode=input.transportation_mode, + weekly_distance=input.distance, + size=input.size, + fuel_type=input.fuel_type, + occupancy=input.occupancy, + passengers=input.passengers, + ) # Calculate monthly co2 - monthly_co2e = WEEKS_PER_MONTH * (input.workweeks / WEEKS_PER_YEAR) * weekly_co2e - dates = np.arange(np.datetime64(input.from_timestamp, "M"), - np.datetime64(input.to_timestamp, "M") + np.timedelta64(1, 'M'), - np.timedelta64(1, "M")).astype('datetime64[D]') + monthly_co2e = ( + WEEKS_PER_MONTH * (input.workweeks / WEEKS_PER_YEAR) * weekly_co2e + ) + dates = np.arange( + np.datetime64(input.from_timestamp, "M"), + np.datetime64(input.to_timestamp, "M") + np.timedelta64(1, "M"), + np.timedelta64(1, "M"), + ).astype("datetime64[D]") for d in dates: - commuting_instance = Commuting(timestamp=str(d), - distance=input.distance, - transportation_mode=input.transportation_mode, - co2e=monthly_co2e, - user=user, - working_group=user.working_group) + commuting_instance = Commuting( + timestamp=str(d), + distance=input.distance, + transportation_mode=input.transportation_mode, + co2e=monthly_co2e, + user=user, + working_group=user.working_group, + ) commuting_instance.save() # Update emissions of working group for date and transportation mode - entries = Commuting.objects.filter(working_group=user.working_group, - transportation_mode=input.transportation_mode, - timestamp=str(d)) - metrics = { - "co2e": Sum("co2e"), - "distance": Sum("distance") - } + if user.working_group is None: + continue + + entries = Commuting.objects.filter( + working_group=user.working_group, + transportation_mode=input.transportation_mode, + timestamp=str(d), + ) + metrics = {"co2e": Sum("co2e"), "distance": Sum("distance")} group_data = entries.aggregate(**metrics) co2e_cap = group_data["co2e"] / user.working_group.n_employees - commuting_group_instance = CommutingGroup(working_group=user.working_group, - timestamp=str(d), - transportation_mode=input.transportation_mode, - n_employees=user.working_group.n_employees, - co2e=group_data["co2e"], - co2e_cap=co2e_cap, - distance=group_data["distance"]) + commuting_group_instance = CommutingGroup( + working_group=user.working_group, + timestamp=str(d), + transportation_mode=input.transportation_mode, + n_employees=user.working_group.n_employees, + co2e=group_data["co2e"], + co2e_cap=co2e_cap, + distance=group_data["distance"], + ) commuting_group_instance.save() - return CreateCommuting(ok=ok) - + return CreateCommuting(success=success) class Mutation(AuthMutation, graphene.ObjectType): + """GraphQL Mutations""" + create_businesstrip = CreateBusinessTrip.Field() create_electricity = CreateElectricity.Field() create_heating = CreateHeating.Field() create_commuting = CreateCommuting.Field() - - -schema = graphene.Schema(query=Query, mutation=Mutation) \ No newline at end of file + request_join_working_group = RequestJoinWorkingGroup.Field() + set_working_group = SetWorkingGroup.Field() + remove_user_from_working_group = RemoveUserFromWorkingGroup.Field() + add_user_to_working_group = AddUserToWorkingGroup.Field() + delete_working_group = DeleteWorkingGroup.Field() + create_working_group = CreateWorkingGroup.Field() + leave_working_group = LeaveWorkingGroup.Field() + plan_trip = PlanTrip.Field() + answer_join_request = AnswerJoinRequest.Field() + + +schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/backend/src/emissions/signals.py b/backend/src/emissions/signals.py new file mode 100644 index 00000000..490c31ec --- /dev/null +++ b/backend/src/emissions/signals.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Functions to catch signals when models are being edited""" + +from django.dispatch import receiver +from django.db.models.signals import pre_save + +from emissions.models import (CustomUser, WorkingGroup) + +import logging + +logger = logging.getLogger(__name__) + + +@receiver(pre_save, sender=CustomUser) +def set_id_as_username(sender, instance, **kwargs): + """Sets the id of the user as its username since django.contrib.auth.models.AbstractUser requires one.""" + if not instance.username: + instance.username = instance.id + print(instance.username) + + +@receiver(pre_save, sender=WorkingGroup) +def update_representatives(sender, instance, **kwargs): + """Updates the info of the old and new representatives of the working group""" + # Update old representative + try: + old_instance = WorkingGroup.objects.get(id=instance.id) + old_representative = old_instance.representative + old_representative.is_representative = False + old_representative.save() + except WorkingGroup.DoesNotExist: + logger.info("Group does not exist yet") + + # Update new representative + new_representative = instance.representative + new_representative.is_representative = True + new_representative.save() + + diff --git a/backend/src/emissions/tests.py b/backend/src/emissions/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/src/emissions/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/src/emissions/tests/__init__.py b/backend/src/emissions/tests/__init__.py new file mode 100644 index 00000000..b40a7755 --- /dev/null +++ b/backend/src/emissions/tests/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init tests""" diff --git a/backend/src/emissions/tests/conftest.py b/backend/src/emissions/tests/conftest.py new file mode 100644 index 00000000..b47bd05c --- /dev/null +++ b/backend/src/emissions/tests/conftest.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Conftest for shared pytest fixtures""" + +import logging +from pathlib import Path + +import requests +import pytest +import os +from dotenv import load_dotenv +import json + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + +with open("../data/test_data.json") as f: + test_data_users = json.load(f)["users"] + + +@pytest.fixture(scope="session") +def test_user1_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user1"]["email"], + "password": test_data_users["test_user1"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + + +@pytest.fixture(scope="session") +def test_user2_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user2"]["email"], + "password": test_data_users["test_user2"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + + +@pytest.fixture(scope="session") +def test_user3_rep_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user3_representative"]["email"], + "password": test_data_users["test_user3_representative"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + + + +@pytest.fixture(scope="session") +def test_user4_rep_token(): + """Log in test user and yield token""" + + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user4_representative"]["email"], + "password": test_data_users["test_user4_representative"]["password"] + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + yield data["data"]["tokenAuth"]["token"] + + diff --git a/backend/src/emissions/tests/test_add_data.py b/backend/src/emissions/tests/test_add_data.py new file mode 100644 index 00000000..dedd597e --- /dev/null +++ b/backend/src/emissions/tests/test_add_data.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to adding data by user""" + +import requests +import logging +from dotenv import load_dotenv +import os +import json + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + +with open("../data/test_data.json") as f: + test_data_users = json.load(f)["users"] + + +def test_add_electricity_data_not_representative(test_user1_token): + """Add electricity data by authenticated user""" + query = """ + mutation { + createElectricity (input: { + timestamp: "2020-10-01" + consumption: 3000 + fuelType: "solar" + building: "348" + groupShare: 1 + }) { + success + electricity { + timestamp + consumption + building + fuelType + co2e + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert ( + data["errors"][0]["message"] + == "Only group representatives have permission to perform this action." + ) + + +def test_add_electricity_data(test_user3_rep_token): + """Add electricity data by authenticated group representative""" + query = """ + mutation { + createElectricity (input: { + timestamp: "2020-12-01" + consumption: 3000 + fuelType: "Solar" + building: "348" + groupShare: 1 + }) { + success + electricity { + timestamp + consumption + building + fuelType + co2e + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["createElectricity"]["success"] + assert data["data"]["createElectricity"]["electricity"]["consumption"] == 3000.0 + + +def test_add_heating_data(test_user3_rep_token): + """Add heating data by authenticated group representative""" + query = """ + mutation createHeating{ + createHeating (input: { + building: "348" + timestamp: "2022-10-01" + consumption: 3000 + unit: "l" + fuelType: "Oil" + groupShare: 1 + }) { + success + heating { + timestamp + consumption + fuelType + co2e + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert data["data"]["createHeating"]["success"] + assert data["data"]["createHeating"]["heating"]["consumption"] == 3000.0 + + +def test_add_businesstrip_data(test_user1_token): + """Add businesstrip data by authenticated user""" + query = """ + mutation { + createBusinesstrip (input: { + timestamp: "2020-01-01" + transportationMode: "Car" + distance: 200 + size: "Medium" + fuelType: "Gasoline" + passengers: 1 + roundtrip: false + }) { + success + businesstrip { + distance + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["createBusinesstrip"]["success"] + assert data["data"]["createBusinesstrip"]["businesstrip"]["distance"] == 200.0 + + +def test_add_commuting_data(test_user1_token): + """Add commuting data by authenticated user""" + query = """ + mutation createCommuting { + createCommuting (input: { + transportationMode: "Car" + distance: 30 + fromTimestamp: "2017-01-01" + toTimestamp: "2017-06-01" + fuelType: "Gasoline" + size: "Medium" + passengers: 1 + workweeks: 40 + }) { + success + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["createCommuting"]["success"] diff --git a/backend/src/emissions/tests/test_authentication.py b/backend/src/emissions/tests/test_authentication.py new file mode 100644 index 00000000..31d46943 --- /dev/null +++ b/backend/src/emissions/tests/test_authentication.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to user authentication""" +import logging +import os +import requests +from dotenv import load_dotenv, find_dotenv +import json + +logger = logging.getLogger(__file__) + +# Load settings from ./.env file +load_dotenv(find_dotenv()) + +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +TEST_EMAIL = os.environ.get("TEST_EMAIL") +TEST_PASSWORD = os.environ.get("TEST_PASSWORD") +TOKEN = "" +REFRESH_TOKEN = "" + +with open("../data/test_data.json") as f: + test_data_users = json.load(f)["users"] + + +def test_login(test_user1_token): + """Test user login""" + query = """ + mutation ($email: String!, $password: String!){ + tokenAuth ( + email: $email + password: $password + ) { + success + errors + token + refreshToken + } + } + """ + variables = {"email": test_data_users["test_user1"]["email"], + "password": test_data_users["test_user1"]["password"]} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["tokenAuth"]["success"] + + assert data["data"]["tokenAuth"]["token"] == test_user1_token + global REFRESH_TOKEN + REFRESH_TOKEN = data["data"]["tokenAuth"]["refreshToken"] + + +def test_verify_token(test_user1_token): + """Test if token can be verified""" + verify_token_query = """ + mutation ($token: String!){ + verifyToken( + token: $token + ) { + success, + errors, + payload + } + } + """ + variables = {"token": test_user1_token} + response = requests.post( + GRAPHQL_URL, json={"query": verify_token_query, "variables": variables} + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["verifyToken"]["success"] + + +def test_me_query(test_user3_rep_token): + """Test whether me query returns the currently logged in user""" + me_query = """ + query { + me { + verified + firstName + workingGroup { + id + name + } + } + } + """ + headers = {"Content-Type": "application/json", "Authorization": f"JWT {test_user3_rep_token}"} + response = requests.post(GRAPHQL_URL, json={"query": me_query}, headers=headers) + assert response.status_code == 200 + data = response.json() + assert data["data"]["me"]["firstName"] == test_data_users["test_user3_representative"]["first_name"] + assert data["data"]["me"]["verified"] + assert data["data"]["me"]["workingGroup"] is not None + + +def test_update_query(test_user1_token): + """Test whether user data can be updated""" + update_query = """ + mutation { + updateAccount ( + firstName: "Louise" + lastName: "Ise" + ) { + success + errors + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": update_query}, headers=headers) + assert response.status_code == 200 + data = response.json() + assert data["data"]["updateAccount"]["success"] + + # reset user data + update_query = """ + mutation ($first_name: String!, $last_name: String!) { + updateAccount ( + firstName: $first_name + lastName: $last_name + ) { + success + errors + } + } + """ + variables = { + "first_name": test_data_users["test_user1"]["first_name"], + "last_name": test_data_users["test_user1"]["last_name"], + } + response = requests.post(GRAPHQL_URL, json={"query": update_query, "variables": variables}, headers=headers) + assert response.status_code == 200 + + +def test_change_password(test_user1_token): + """Test whether password can be changed""" + new_password = "123456super" + change_password_query = """ + mutation ($password: String!, $new_password: String!) { + passwordChange( + oldPassword: $password, + newPassword1: $new_password, + newPassword2: $new_password + ) { + success, + errors, + token, + refreshToken + } + }""" + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + variables = { + "password": test_data_users["test_user1"]["password"], + "new_password": new_password + } + response = requests.post( + GRAPHQL_URL, json={"query": change_password_query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["passwordChange"]["success"] + global TOKEN + TOKEN = data["data"]["passwordChange"]["token"] + + # reset changes (cleanup) + change_back_password_query = """ + mutation ($password: String!, $new_password: String!) { + passwordChange( + oldPassword: $new_password, + newPassword1: $password, + newPassword2: $password + ) { + success, + errors, + token, + refreshToken + } + }""" + response = requests.post( + GRAPHQL_URL, json={"query": change_back_password_query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + + +def test_list_users(): + """Test to query all users""" + query = """ + query { + users { + edges { + node { + email + } + } + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["users"]["edges"]) > 1 + + +def test_query_dropdown_options(): + """Test if querying dropdown options""" + query = """ + { __type(name: "ElectricityFuelType") { + enumValues { + name + description + } + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + assert data["data"]["__type"]["enumValues"][0]["name"] == "GERMAN_ENERGY_MIX" + + query = """ + {__type(name: "Unit") { + enumValues + { + name + description + } + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + response.json() diff --git a/backend/src/emissions/tests/test_emailclient.py b/backend/src/emissions/tests/test_emailclient.py new file mode 100644 index 00000000..09db64cc --- /dev/null +++ b/backend/src/emissions/tests/test_emailclient.py @@ -0,0 +1,38 @@ +from dotenv import load_dotenv +load_dotenv("../../.env") +from backend.src.emissions.email_client import EmailClient +from django.conf import settings + +TEMPLATE_DIR = settings.TEMPLATES[0]['DIRS'][0] + + +def test_emailclient(): + """Tests whether setting up EmailClient works""" + client = EmailClient(template_dir=TEMPLATE_DIR) + assert isinstance(client, EmailClient) + + +def test_get_template_email(): + """Tests whether getting an email template works""" + client = EmailClient(template_dir=TEMPLATE_DIR) + email_text, html_text = client.get_template_email('join_request') + assert isinstance(email_text, str) + + +def test_get_template_subject(): + """Tests whether getting an email subject template works""" + client = EmailClient(template_dir=TEMPLATE_DIR) + email_text = client.get_template_subject('join_request') + assert isinstance(email_text, str) + + +def test_send_email(): + """Tests whether sending an email works""" + + client = EmailClient(template_dir=TEMPLATE_DIR) + from_email = "no-reply@pledge4future.org" + to_email = "christina_ludwig@gmx.net" + + _, html_text = client.get_template_email('join_request') + email_subject = client.get_template_subject('join_request') + client.send_email(email_subject, html_text, from_email, to_email) diff --git a/backend/src/emissions/tests/test_plantrip.py b/backend/src/emissions/tests/test_plantrip.py new file mode 100644 index 00000000..88a492aa --- /dev/null +++ b/backend/src/emissions/tests/test_plantrip.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""__description__""" + +__author__ = "Christina Ludwig, GIScience Research Group, Heidelberg University" +__email__ = "christina.ludwig@uni-heidelberg.de" + + +import pytest +import requests +from dotenv import load_dotenv +import os +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + +@pytest.mark.parametrize('transportation_mode', ['Car']) +@pytest.mark.parametrize('size', ['small', 'medium', 'large', 'average']) +@pytest.mark.parametrize('fuel_type', ['diesel', 'gasoline', 'cng', 'electric', 'hybrid', 'plug-in_hybrid', 'average']) +@pytest.mark.parametrize('passengers', [1]) +@pytest.mark.parametrize('round_trip', [False]) +def test_plan_trip_car(transportation_mode, size, fuel_type, passengers, round_trip): + """Test whether trip planner throws error for different parameter combinations for car trips""" + query = """ + mutation planTrip ($transportationMode: String!, $size: String!, $fuelType: String!, $passengers: Int!, $roundTrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + distance: 200 + size: $size + fuelType: $fuelType + passengers: $passengers + roundtrip: $roundTrip + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "size": size, "fuelType": fuel_type, "passengers": passengers, "roundTrip": round_trip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + +@pytest.mark.parametrize('transportation_mode', ['Bus']) +@pytest.mark.parametrize('size', ['medium', 'large', 'average']) +@pytest.mark.parametrize('fuel_type', ['diesel']) +@pytest.mark.parametrize('occupancy', [20., 50., 80., 100.]) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_bus(transportation_mode, size, fuel_type, occupancy, roundtrip): + """Test whether trip planner throws error for different parameter combinations for bus trips""" + query = """ + mutation planTrip ($transportationMode: String!, $size: String!, $fuelType: String!, $occupancy: Float!, + $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + size: $size + fuelType: $fuelType + occupancy: $occupancy + roundtrip: $roundtrip + distance: 200.0 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "size": size, "fuelType": fuel_type, + "occupancy": occupancy, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + + +@pytest.mark.parametrize('transportation_mode', ['Train']) +@pytest.mark.parametrize('fuel_type', ['diesel', 'electric', 'average']) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_train(transportation_mode, fuel_type, roundtrip): + """Test whether trip planner throws error for different parameter combinations for train trips""" + query = """ + mutation planTrip ($transportationMode: String!, $fuelType: String!, $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + fuelType: $fuelType + roundtrip: $roundtrip + distance: 200 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "fuelType": fuel_type, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + + +@pytest.mark.parametrize('transportation_mode', ['Plane']) +@pytest.mark.parametrize('seating_class', ['average', 'Economy class', 'Premium economy class', 'Business class', 'First class']) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_plane(transportation_mode, seating_class, roundtrip): + """Test whether trip planner throws error for different parameter combinations for plane trips""" + query = """ + mutation planTrip ($transportationMode: String!, $seatingClass: String!, $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + seatingClass: $seatingClass + roundtrip: $roundtrip + distance: 500 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "seatingClass": seating_class, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] + + +@pytest.mark.parametrize('transportation_mode', ['Ferry']) +@pytest.mark.parametrize('seating_class', ['average', 'Foot passenger', 'Car passenger']) +@pytest.mark.parametrize('roundtrip', [True, False]) +def test_plan_trip_ferry(transportation_mode, seating_class, roundtrip): + """Test whether trip planner throws error for different parameter combinations for ferry trips""" + query = """ + mutation planTrip ($transportationMode: String!, $seatingClass: String!, $roundtrip: Boolean!) { + planTrip (input: { + transportationMode: $transportationMode + seatingClass: $seatingClass + roundtrip: $roundtrip + distance: 200 + }) { + success + message + co2e + } + } + """ + + headers = { + "Content-Type": "application/json", + } + variables = {"transportationMode": transportation_mode, "seatingClass": seating_class, "roundtrip": roundtrip} + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + logger.warning(response.content) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert data["data"]["planTrip"]["success"] \ No newline at end of file diff --git a/backend/src/emissions/tests/test_query_data.py b/backend/src/emissions/tests/test_query_data.py new file mode 100644 index 00000000..6f23c27c --- /dev/null +++ b/backend/src/emissions/tests/test_query_data.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to querying data""" + +import requests +import logging +from dotenv import load_dotenv +import os + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + + +def test_query_heating_aggregated(test_user3_rep_token): + """Query aggregated heating data by authenticated user""" + query = """ + query ($level: String!) { + heatingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "group"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["heatingAggregated"][0]["date"], str) + assert isinstance(data["data"]["heatingAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["heatingAggregated"][0]["co2eCap"], float) + assert len(data["data"]["heatingAggregated"]) == 2 + + +def test_query_heating_aggregated_personal(test_user3_rep_token): + """Query aggregated heating data by authenticated user""" + query = """ + query ($level: String!) { + heatingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["heatingAggregated"][0]["date"], str) + assert isinstance(data["data"]["heatingAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["heatingAggregated"][0]["co2eCap"], float) + assert len(data["data"]["heatingAggregated"]) == 2 + assert data["data"]["heatingAggregated"][0]["co2eCap"] == data["data"]["heatingAggregated"][0]["co2e"] + +def test_query_heating_aggregated_no_workinggroup(test_user1_token): + """Query aggregated heating data by authenticated user""" + query = """ + query ($level: String!) { + heatingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert data["errors"][0]["message"] == 'No heating data available, since user is not assigned to any working group yet.' + + +def test_query_electricity_aggregated_institution(test_user3_rep_token): + """Query aggregated electricity data by authenticated user""" + query = """ + query ($level: String!) { + electricityAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "institution"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["electricityAggregated"][0]["date"], str) + assert isinstance(data["data"]["electricityAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["electricityAggregated"][0]["co2eCap"], float) + assert len(data["data"]["electricityAggregated"]) == 1 + + + +def test_query_electricity_aggregated_institution(test_user3_rep_token): + """Query aggregated electricity data by authenticated user""" + query = """ + query ($level: String!) { + electricityAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "institution"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert isinstance(data["data"]["electricityAggregated"][0]["date"], str) + assert isinstance(data["data"]["electricityAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["electricityAggregated"][0]["co2eCap"], float) + assert len(data["data"]["electricityAggregated"]) == 2 + + + +def test_query_businesstrip_aggregated_personal(test_user1_token): + """Query aggregated businesstrip data by authenticated user""" + query = """ + query ($level: String!) { + businesstripAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert isinstance(data["data"]["businesstripAggregated"][0]["date"], str) + assert isinstance(data["data"]["businesstripAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["businesstripAggregated"][0]["co2eCap"], float) + + +def test_query_commuting_aggregated_group(test_user1_token): + """Query aggregated commuting data by authenticated user""" + query = """ + query ($level: String!) { + commutingAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "personal"} + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + logger.warning(data) + assert isinstance(data["data"]["commutingAggregated"][0]["date"], str) + assert isinstance(data["data"]["commutingAggregated"][0]["co2e"], float) + assert isinstance(data["data"]["commutingAggregated"][0]["co2eCap"], float) + + +def test_query_electricity_aggregated_with_invalid_token(): + """Query aggregated electricity data by non authenticated user should return an error message but no data""" + query = """ + query ($level: String!) { + electricityAggregated (level: $level) { + date + co2e + co2eCap + } + } + """ + variables = {"level": "institution"} + headers = { + "Content-Type": "application/json", + "Authorization": "JWT invalid_token", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert ( + data["errors"][0]["message"] + == "You do not have permission to perform this action" + ) + + + + +def test_resolve_institutions(): + """List all institutions""" + query = """ + query { + institutions { + id + name + city + state + country + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert len(data["data"]["institutions"]) > 0 + + +def test_resolve_research_fields(): + """List all research fields""" + query = """ + query { + researchfields { + field + subfield + } + } + """ + response = requests.post(GRAPHQL_URL, json={"query": query}) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert data["data"]["researchfields"][0]["field"] == "Natural Sciences" + diff --git a/backend/src/emissions/tests/test_working_groups.py b/backend/src/emissions/tests/test_working_groups.py new file mode 100644 index 00000000..1a94f433 --- /dev/null +++ b/backend/src/emissions/tests/test_working_groups.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Testing of GraphQL API queries related to management of working groups""" + +import requests +import logging +from dotenv import load_dotenv +import os +import json + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) +logger.addHandler(logging.StreamHandler()) + +# Load settings from ./.env file +load_dotenv("../../../../.env") +GRAPHQL_URL = os.environ.get("GRAPHQL_URL") +logger.info(GRAPHQL_URL) + + +with open("../data/test_data.json") as f: + test_workinggroups = json.load(f)["working_groups"] + + +def test_set_workinggroup(test_user1_token): + """Test whether user data can be updated""" + query = """ + mutation ($id: String!){ + setWorkingGroup (input: { + id: $id + } + ) { + success + user { + email + workingGroup { + name + } + } + } + } + """ + variables = { + "id": test_workinggroups['working_group1']['id'] + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + logger.info(data["data"]) + assert data["data"]["setWorkingGroup"]["success"] + assert ( + data["data"]["setWorkingGroup"]["user"]["workingGroup"]["name"] + == test_workinggroups['working_group1']["name"] + ) + + +def test_resolve_working_groups(test_user1_token): + """List all working groups""" + query = """ + query { + workinggroups { + id + name + field { + field + subfield + } + } + } + """ + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query}, headers=headers) + assert response.status_code == 200 + data = response.json() + # logger.warning(data) + assert len(data["data"]["workinggroups"]) > 0 + + +def test_create_workinggroup(test_user2_token): + """Create a new working group""" + query = """ + mutation ($name: String!, $institution_id: String!, $research_field_id: Int!, $nemployees: Int!, $is_public: Boolean!){ + createWorkingGroup (input: { + name: $name + institutionId: $institution_id + researchFieldId: $research_field_id + nEmployees: $nemployees + isPublic: $is_public + }) { + success + workinggroup { + name + representative { + email + } + } + } + } + """ + variables = { + "name": test_workinggroups['working_group1']['name'] + "test", + "institution_id": test_workinggroups['working_group1']['institution']['id'], + "research_field_id": test_workinggroups['working_group1']['research_field']['id'], + "nemployees": test_workinggroups['working_group1']['n_employees'], + "is_public": test_workinggroups['working_group1']['is_public'], + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user2_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + logger.warning(response.content) + data = response.json() + logger.warning(data) + assert data["data"]["createWorkingGroup"]["success"] + assert ( + data["data"]["createWorkingGroup"]["workinggroup"]["representative"]["email"] is not None + ) + # todo: delete working group after it has been created + +def test_delete_workinggroup_by_representative(test_user2_token): + """Tests whether working groups can be deleted""" + query = """ + mutation ($name: String!, $institution_id: String!, $research_field_id: Int!, $nemployees: Int!, $is_public: Boolean!){ + createWorkingGroup (input: { + name: $name + institutionId: $institution_id + researchFieldId: $research_field_id + nEmployees: $nemployees + isPublic: $is_public + }) { + success + workinggroup { + name + representative { + email + } + } + } + } + """ + variables = { + "name": test_workinggroups['workinggroup_to_delete']['name'], + "institution_id": test_workinggroups['workinggroup_to_delete']['institution']['id'], + "research_field_id": test_workinggroups['workinggroup_to_delete']['research_field']['id'], + "nemployees": test_workinggroups['workinggroup_to_delete']['n_employees'], + "is_public": test_workinggroups['workinggroup_to_delete']['is_public'], + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user2_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + + workinggroup_id = test_workinggroups["workinggroup_to_delete"]["id"] + mutation = ''' + mutation ($workingGroupId: String!){ + deleteWorkingGroup (input: { + id: $workingGroupId + }) { + success + } + } + ''' + variables = { + "id": workinggroup_id + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + assert response.success == True + + + +def test_create_workinggroup_by_representative(test_user3_rep_token): + """Create a new working group""" + query = """ + mutation ($name: String!, $institution_id: String!, $research_field_id: Int!, $nemployees: Int!, $is_public: Boolean!){ + createWorkingGroup (input: { + name: $name + institutionId: $institution_id + researchFieldId: $research_field_id + nEmployees: $nemployees + isPublic: $is_public + }) { + success + workinggroup { + name + representative { + email + } + } + } + } + """ + variables = { + "name": test_workinggroups['working_group1']['name'] + "test2", + "institution_id": test_workinggroups['working_group1']['institution']['id'], + "research_field_id": test_workinggroups['working_group1']['research_field']['id'], + "nemployees": test_workinggroups['working_group1']['n_employees'], + "is_public": test_workinggroups['working_group1']['is_public'], + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response = requests.post(GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers) + assert response.status_code == 200 + logger.warning(response.content) + data = response.json() + assert ( + data["errors"][0]["message"] + == "This is wrong cannot create a new working group, since they are already the representative of another working group." + ) + + +def test_join_request_workinggroup(test_user1_token, test_user3_rep_token, test_user4_rep_token): + """Test whether user data can be updated""" + query = """ + mutation ($id: String!){ + requestJoinWorkingGroup (input: { + workinggroupId: $id + } + ) { + success + joinRequest { + status + id + workingGroup { + id + } + } + } + } + """ + variables = { + "id": test_workinggroups['working_group1']['id'] + } + headers = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user1_token}", + } + response = requests.post( + GRAPHQL_URL, json={"query": query, "variables": variables}, headers=headers + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]['requestJoinWorkingGroup']['joinRequest']["workingGroup"]["id"] == test_workinggroups['working_group1']['id'] + assert data["data"]['requestJoinWorkingGroup']['joinRequest']["status"] == 'PENDING' + request_id = data["data"]['requestJoinWorkingGroup']['joinRequest']['id'] + + # test if error is raised if person other than the group representative answers + query2 = """ + mutation ($requestId: String!, $approve: Boolean!){ + answerJoinRequest (input: { + approve: $approve + requestId: $requestId + } + ) { + success + requestingUser { + workingGroup { + id + } + } + } + } + """ + variables2 = { + "requestId": request_id, + "approve": True + } + headers2 = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user4_rep_token}", + } + response2 = requests.post( + GRAPHQL_URL, json={"query": query2, "variables": variables2}, headers=headers2 + ) + assert response2.status_code == 200 + data2 = response2.json() + assert ( + "You are not authorized to answer this join request" in data2["errors"][0]["message"] + ) + + # Test if request can be approved + query3 = """ + mutation ($requestId: String!, $approve: Boolean!){ + answerJoinRequest (input: { + approve: $approve + requestId: $requestId + } + ) { + success + requestingUser { + workingGroup { + id + } + } + } + } + """ + variables3 = { + "requestId": request_id, + "approve": True + } + headers3 = { + "Content-Type": "application/json", + "Authorization": f"JWT {test_user3_rep_token}", + } + response3 = requests.post( + GRAPHQL_URL, json={"query": query3, "variables": variables3}, headers=headers3 + ) + assert response3.status_code == 200 + data3 = response3.json() + assert data3['data']["answerJoinRequest"]['success'] + assert data3['data']["answerJoinRequest"]['requestingUser']['workingGroup']['id'] == test_workinggroups['working_group1']['id'] + diff --git a/backend/src/emissions/views.py b/backend/src/emissions/views.py index b91e46a5..b8005d8b 100644 --- a/backend/src/emissions/views.py +++ b/backend/src/emissions/views.py @@ -1,3 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""init""" + from django.shortcuts import render -# Create your views here. \ No newline at end of file +# Create your views here. diff --git a/backend/src/entryPoint.sh b/backend/src/entryPoint.sh index 4ed33b89..5da7395b 100755 --- a/backend/src/entryPoint.sh +++ b/backend/src/entryPoint.sh @@ -1,6 +1,18 @@ #!/bin/bash +rm emissions/migrations/00*.py python manage.py makemigrations emissions python manage.py migrate -python manage.py create_groups -python manage.py populate_data -python manage.py runserver 0.0.0.0:8000 \ No newline at end of file +python manage.py load_institutions +python manage.py loaddata research_fields.json +python manage.py create_test_data +python manage.py graph_models emissions -a -o /home/python/app/assets/database_structure.png --pydot +#python manage.py populate_data +python manage.py collectstatic --noinput +python manage.py check --deploy +if [ "$DJANGO_DEBUG" = 'True' ] ; then + echo "Using development server" + python manage.py runserver 0.0.0.0:8000 +else + echo "Using production server" + gunicorn -b 0.0.0.0:8000 pledge4future.wsgi +fi diff --git a/backend/src/manage.py b/backend/src/manage.py index dd31b70f..53f8e1fc 100755 --- a/backend/src/manage.py +++ b/backend/src/manage.py @@ -1,12 +1,15 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- + """Django's command-line utility for administrative tasks.""" + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wepledge.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pledge4future.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +21,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/backend/src/pledge4future/__init__.py b/backend/src/pledge4future/__init__.py new file mode 100644 index 00000000..c2444f4b --- /dev/null +++ b/backend/src/pledge4future/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""__init__""" diff --git a/backend/src/wepledge/asgi.py b/backend/src/pledge4future/asgi.py similarity index 64% rename from backend/src/wepledge/asgi.py rename to backend/src/pledge4future/asgi.py index af40bfda..cf8641cb 100644 --- a/backend/src/wepledge/asgi.py +++ b/backend/src/pledge4future/asgi.py @@ -1,5 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + """ -ASGI config for wepledge project. +ASGI config for pledge4future project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +14,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wepledge.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pledge4future.settings") application = get_asgi_application() diff --git a/backend/src/pledge4future/settings.py b/backend/src/pledge4future/settings.py new file mode 100644 index 00000000..1e3cb8e3 --- /dev/null +++ b/backend/src/pledge4future/settings.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Django settings for pledg4future project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +from datetime import timedelta +from pathlib import Path +import os +from dotenv import load_dotenv, find_dotenv + +import django +from django.utils.encoding import force_str +django.utils.encoding.force_text = force_str + + +# Load settings from ./.env file +# load_dotenv("../../.env", verbose=True) +#load_dotenv(find_dotenv()) + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +print(BASE_DIR) + +STATIC_ROOT = BASE_DIR.parent / "static" +MEDIA_ROOT = BASE_DIR.parent / "media" + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! - is set in local .env file +SECRET_KEY = os.environ.get("SECRET_KEY") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False #os.environ.get("DJANGO_DEBUG") + +ALLOWED_HOSTS = ["http://localhost", "localhost", "0.0.0.0", "http://api.test-pledge4future.heigit.org"] + +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOW_CREDENTIALS = False +CORS_ALLOWED_ORIGINS = ["http://localhost:3000","https://test-pledge4future.heigit.org"] + +CSRF_COOKIE_SECURE=True +SESSION_COOKIE_SECURE=True +SECURE_HSTS_SECONDS=30 +SECURE_HSTS_INCLUDE_SUBDOMAINS=True + +# Application definition +INSTALLED_APPS = [ + 'emissions.apps.EmissionsConfig', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "graphene_django", + "graphql_jwt.refresh_token.apps.RefreshTokenConfig", + "graphql_auth", + "django_filters", + "django_extensions", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware" +] + +ROOT_URLCONF = "pledge4future.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "./templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +WSGI_APPLICATION = "pledge4future.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +# local database for development +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.sqlite3", +# "NAME": os.path.join(BASE_DIR, "db.sqlitedb"), +# } +# } + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "USER": "postgres", + "HOST": "db", + "PORT": 5432, + } +} + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +STATIC_URL = "/static/" + +AUTH_USER_MODEL = "emissions.CustomUser" + +GRAPHENE = { + "SCHEMA": "emissions.schema.schema", + "MIDDLEWARE": ["graphql_jwt.middleware.JSONWebTokenMiddleware"], +} + +AUTHENTICATION_BACKENDS = [ + "graphql_auth.backends.GraphQLAuthBackend", + "django.contrib.auth.backends.ModelBackend", +] + +GRAPHQL_JWT = { + # "JWT_AUTH_HEADER_NAME": "HTTP_Authorization", + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_VERIFY_EXPIRATION": True, + "JWT_EXPIRATION_DELTA": timedelta(hours=24), + "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=7), + "JWT_LONG_RUNNING_REFRESH_TOKEN": True, + "JWT_ALLOW_ANY_CLASSES": [ + "graphql_auth.mutations.Register", + "graphql_auth.mutations.VerifyAccount", + "graphql_auth.mutations.ResendActivationEmail", + "graphql_auth.mutations.SendPasswordResetEmail", + "graphql_auth.mutations.PasswordReset", + "graphql_auth.mutations.ObtainJSONWebToken", + "graphql_auth.mutations.VerifyToken", + "graphql_auth.mutations.RefreshToken", + "graphql_auth.mutations.RevokeToken", + "graphql_auth.mutations.VerifySecondaryEmail", + ], +} + +GRAPHQL_AUTH = { + "LOGIN_ALLOWED_FIELDS": ["email"], + "REGISTER_MUTATION_FIELDS": [ + "email", + "first_name", + "last_name", + ], + "REGISTER_MUTATION_FIELDS_OPTIONAL": ["academic_title", "first_name", "last_name"], + "UPDATE_MUTATION_FIELDS": [ + "first_name", + "last_name", + "academic_title" + ], # "is_representative", "working_group" - make separate mutation + "ALLOW_DELETE_ACCOUNT": True, + "SEND_ACTIVATION_EMAIL": True, + "ALLOW_LOGIN_NOT_VERIFIED": False, + "EMAIL_FROM": "no-reply@pledge4future.org", + "ACTIVATION_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/confirm-email", + "PASSWORD_RESET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-new-password", + "ACTIVATION_SECONDARY_EMAIL_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/activate-secondary", + "PASSWORD_SET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-password", +} + +# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_TLS = True +EMAIL_USE_SSL = False +EMAIL_PORT = os.environ.get("EMAIL_PORT") +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") +EMAIL_HOST = os.environ.get("EMAIL_HOST") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + + +GRAPH_MODELS = { + "all_applications": True, + "group_models": True, +} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} \ No newline at end of file diff --git a/backend/src/pledge4future/test_settings.py b/backend/src/pledge4future/test_settings.py new file mode 100644 index 00000000..9c206d2c --- /dev/null +++ b/backend/src/pledge4future/test_settings.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Django settings for pledg4future project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +from datetime import timedelta +from pathlib import Path +import os +from dotenv import load_dotenv, find_dotenv + +import django +from django.utils.encoding import force_str +django.utils.encoding.force_text = force_str + + +# Load settings from ./.env file +# load_dotenv("../../.env", verbose=True) +#load_dotenv(find_dotenv()) + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +STATIC_ROOT = BASE_DIR.parent / "static" +MEDIA_ROOT = BASE_DIR.parent / "media" + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! - is set in local .env file +SECRET_KEY = '213' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False #os.environ.get("DJANGO_DEBUG") + +ALLOWED_HOSTS = ["http://localhost", "localhost", "http://api.test-pledge4future.heigit.org"] + +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOW_CREDENTIALS = False +CORS_ALLOWED_ORIGINS = ["http://localhost:3000","https://test-pledge4future.heigit.org"] + +CSRF_COOKIE_SECURE=True +SESSION_COOKIE_SECURE=True +SECURE_HSTS_SECONDS=30 +SECURE_HSTS_INCLUDE_SUBDOMAINS=True + +# Application definition +INSTALLED_APPS = [ + 'emissions.apps.EmissionsConfig', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "graphene_django", + "graphql_jwt.refresh_token.apps.RefreshTokenConfig", + "graphql_auth", + "django_filters", + "django_extensions", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware" +] + +ROOT_URLCONF = "pledge4future.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "./templates")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] + +WSGI_APPLICATION = "pledge4future.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.1/ref/settings/#databases + +# local database for development +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.sqlite3", +# "NAME": os.path.join(BASE_DIR, "db.sqlitedb"), +# } +# } + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "USER": "postgres", + "HOST": "db", + "PORT": 5432, + } +} + +# Password validation +# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +STATIC_URL = "/static/" + +AUTH_USER_MODEL = "emissions.CustomUser" + +GRAPHENE = { + "SCHEMA": "emissions.schema.schema", + "MIDDLEWARE": ["graphql_jwt.middleware.JSONWebTokenMiddleware"], +} + +AUTHENTICATION_BACKENDS = [ + "graphql_auth.backends.GraphQLAuthBackend", + "django.contrib.auth.backends.ModelBackend", +] + +GRAPHQL_JWT = { + # "JWT_AUTH_HEADER_NAME": "HTTP_Authorization", + "JWT_AUTH_HEADER_PREFIX": "JWT", + "JWT_VERIFY_EXPIRATION": True, + "JWT_EXPIRATION_DELTA": timedelta(hours=24), + "JWT_REFRESH_EXPIRATION_DELTA": timedelta(days=7), + "JWT_LONG_RUNNING_REFRESH_TOKEN": True, + "JWT_ALLOW_ANY_CLASSES": [ + "graphql_auth.mutations.Register", + "graphql_auth.mutations.VerifyAccount", + "graphql_auth.mutations.ResendActivationEmail", + "graphql_auth.mutations.SendPasswordResetEmail", + "graphql_auth.mutations.PasswordReset", + "graphql_auth.mutations.ObtainJSONWebToken", + "graphql_auth.mutations.VerifyToken", + "graphql_auth.mutations.RefreshToken", + "graphql_auth.mutations.RevokeToken", + "graphql_auth.mutations.VerifySecondaryEmail", + ], +} + +GRAPHQL_AUTH = { + "LOGIN_ALLOWED_FIELDS": ["email"], + "REGISTER_MUTATION_FIELDS": [ + "email", + "first_name", + "last_name", + ], + "REGISTER_MUTATION_FIELDS_OPTIONAL": ["academic_title", "first_name", "last_name"], + "UPDATE_MUTATION_FIELDS": [ + "first_name", + "last_name", + "academic_title" + ], # "is_representative", "working_group" - make separate mutation + "ALLOW_DELETE_ACCOUNT": True, + "SEND_ACTIVATION_EMAIL": True, + "ALLOW_LOGIN_NOT_VERIFIED": False, + "EMAIL_FROM": "no-reply@pledge4future.org", + "ACTIVATION_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/confirm-email", + "PASSWORD_RESET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-new-password", + "ACTIVATION_SECONDARY_EMAIL_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/activate-secondary", + "PASSWORD_SET_PATH_ON_EMAIL": os.getenv("PUBLIC_URL", "https://localhost") + "/set-password", +} + +# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_TLS = True +EMAIL_USE_SSL = False +EMAIL_PORT = 587 +EMAIL_HOST_USER = "fill_in" +EMAIL_HOST = "fill_in" +EMAIL_HOST_PASSWORD = 'fill_in' + + + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ + + +GRAPH_MODELS = { + "all_applications": True, + "group_models": True, +} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} \ No newline at end of file diff --git a/backend/src/wepledge/urls.py b/backend/src/pledge4future/urls.py similarity index 74% rename from backend/src/wepledge/urls.py rename to backend/src/pledge4future/urls.py index 9e4052cc..065070ff 100644 --- a/backend/src/wepledge/urls.py +++ b/backend/src/pledge4future/urls.py @@ -1,4 +1,7 @@ -"""wepledge URL Configuration +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""pledge4future URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.1/topics/http/urls/ @@ -13,6 +16,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path from graphene_django.views import GraphQLView @@ -22,9 +26,9 @@ from rest_framework_jwt.views import verify_jwt_token urlpatterns = [ - path('admin/', admin.site.urls), - path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True))), - path('api-token-auth/', obtain_jwt_token), - path('api-token-refresh/', refresh_jwt_token), - path('api-token-verify/', verify_jwt_token) + path("admin/", admin.site.urls), + path("graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True))), + path("api-token-auth/", obtain_jwt_token), + path("api-token-refresh/", refresh_jwt_token), + path("api-token-verify/", verify_jwt_token), ] diff --git a/backend/src/wepledge/wsgi.py b/backend/src/pledge4future/wsgi.py similarity index 64% rename from backend/src/wepledge/wsgi.py rename to backend/src/pledge4future/wsgi.py index 42ce972a..9a74a5bd 100644 --- a/backend/src/wepledge/wsgi.py +++ b/backend/src/pledge4future/wsgi.py @@ -1,5 +1,8 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + """ -WSGI config for wepledge project. +WSGI config for pledge4future project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +14,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wepledge.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pledge4future.settings") application = get_wsgi_application() diff --git a/backend/src/preprocessing/make_research_field_fixture.py b/backend/src/preprocessing/make_research_field_fixture.py new file mode 100644 index 00000000..73d53dcb --- /dev/null +++ b/backend/src/preprocessing/make_research_field_fixture.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Make a fixture file for research fields""" + +import json +import copy + + +file = "/data/research_fields.json" +outfile = "./backend/src/emissions/fixtures/research_fields.json" + +with open(file) as src: + data = json.load(src) + +items = [] +item_template = { + "model": "emissions.researchfield", + "pk": None, + "fields": {"field": None, "subfield": None}, +} +pk = 1 + +for field in data.keys(): + print(field) + for subfield in data[field]: + print(subfield) + new_item = copy.deepcopy(item_template) + new_item["pk"] = pk + new_item["fields"]["field"] = field + new_item["fields"]["subfield"] = subfield + items.append(new_item) + pk += 1 + +with open(outfile, "w") as dst: + json.dump(items, dst, indent=2) diff --git a/backend/src/pytest.ini b/backend/src/pytest.ini new file mode 100644 index 00000000..e401be2f --- /dev/null +++ b/backend/src/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = pledge4future.test_settings \ No newline at end of file diff --git a/backend/src/templates/email/activation_email.html b/backend/src/templates/email/activation_email.html new file mode 100644 index 00000000..fed880cf --- /dev/null +++ b/backend/src/templates/email/activation_email.html @@ -0,0 +1,240 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/templates/email/activation_subject.txt b/backend/src/templates/email/activation_subject.txt new file mode 100644 index 00000000..ed475aaa --- /dev/null +++ b/backend/src/templates/email/activation_subject.txt @@ -0,0 +1 @@ +Activate your Pledge4Future account \ No newline at end of file diff --git a/backend/src/templates/email/join_request_email.html b/backend/src/templates/email/join_request_email.html new file mode 100644 index 00000000..53101fc8 --- /dev/null +++ b/backend/src/templates/email/join_request_email.html @@ -0,0 +1,238 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/templates/email/join_request_subject.txt b/backend/src/templates/email/join_request_subject.txt new file mode 100644 index 00000000..44d522da --- /dev/null +++ b/backend/src/templates/email/join_request_subject.txt @@ -0,0 +1 @@ +{{user_first_name}} {{user_last_name}} would like to join your working group at Pledge4Future \ No newline at end of file diff --git a/backend/src/templates/email/password_reset_email.html b/backend/src/templates/email/password_reset_email.html new file mode 100644 index 00000000..baf27cb6 --- /dev/null +++ b/backend/src/templates/email/password_reset_email.html @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/templates/email/password_reset_subject.txt b/backend/src/templates/email/password_reset_subject.txt new file mode 100644 index 00000000..0605e35f --- /dev/null +++ b/backend/src/templates/email/password_reset_subject.txt @@ -0,0 +1 @@ +Reset password of your Pledge4Future account \ No newline at end of file diff --git a/backend/src/wepledge/settings.py b/backend/src/wepledge/settings.py deleted file mode 100644 index 20387998..00000000 --- a/backend/src/wepledge/settings.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Django settings for wepledge project. - -Generated by 'django-admin startproject' using Django 3.1.5. - -For more information on this file, see -https://docs.djangoproject.com/en/3.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.1/ref/settings/ -""" - -from pathlib import Path -import os - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'v)5b9fa=$h@ox@@&w-dmw*093we@e865+7vjuf^1xu6jbot16r' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"] - -CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS - -# Application definition - -INSTALLED_APPS = [ - 'emissions', - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django_extensions', - 'graphene_django', - 'rest_framework', - 'corsheaders', - 'emissions.apps.EmissionsConfig', - 'graphql_jwt.refresh_token.apps.RefreshTokenConfig', - 'graphql_auth', - 'django_filters' -] - -REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', - ), - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.BasicAuthentication', - ), -} - -JWT_ALLOW_REFRESH = True -CORS_ORIGIN_ALLOW_ALL = False -CORS_ALLOW_CREDENTIALS = True -CORS_ORIGIN_WHITELIST = [ - 'http://localhost:3000' -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'wepledge.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'wepledge.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/3.1/ref/settings/#databases - -# local database for development -#DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.sqlite3', -# 'NAME': os.path.join(BASE_DIR, "db.sqlitedb"), -# } -#} - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'postgres', - 'USER': 'postgres', - 'HOST': 'db', - 'PORT': 5432 - } -} - - -# Password validation -# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - -AUTH_USER_MODEL = "emissions.User" - -GRAPHENE = { - 'SCHEMA' : 'emissions.schema.schema', - 'MIDDELWARE' : [ - 'graphql_jwt.middleware.JSONWebTOkenMiddleware', - ] -} - -AUTHENTICATION_BACKENDS = [ - ##'graphql_jwt.backends.JSONWebTokenBackend', - 'graphql_auth.backends.GraphQLAuthBackend', - 'django.contrib.auth.backends.ModelBackend' -] - -GRAPHQL_JWT = { - "JWT_ALLOW_ANY_CLASSES": [ - "graphql_auth.mutations.Register", - "graphql_auth.mutations.VerifyAccount", - "graphql_auth.mutations.VerifyToken", - "graphql_auth.mutations.ObtainJSONWebToken", - "graphql_auth.mutations.RefreshToken", - "graphql_auth.mutations.PasswordReset", - "graphql_auth.mutations.PasswordChange", - "graphql_auth.mutations.UpdateAccount", - "graphql_auth.mutations.SendPasswordResetEmail", - "graphql_auth.mutations.ResendActivationEmail", - - ], - "JWT_VERIFY_EXPIRATION": True, - "JWT_LONG_RUNNING_REFRESH_TOKEN": True, -} - -GRAPHQL_AUTH = { - 'LOGIN_ALLOWED_FIELDS': ['email', 'username'], - 'SEND_ACTIVATION_EMAIL': True, - 'EMAIL_FROM': 'no-reply@pledge4future.org', -} - - -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' -#EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'mail.greensta.de' -EMAIL_USE_TLS = True -EMAIL_USE_SSL: False -EMAIL_PORT = 587 -EMAIL_HOST_USER = 'no-reply@pledge4future.org' -EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') - -# Internationalization -# https://docs.djangoproject.com/en/3.1/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.1/howto/static-files/ - -STATIC_ROOT = 'static' -STATIC_URL = '/backend/static/' - - -GRAPH_MODELS = { - 'all_applications': True, - 'group_models': True, -} \ No newline at end of file diff --git a/clean.sh b/clean.sh old mode 100644 new mode 100755 index b9b496ea..adb3ca66 --- a/clean.sh +++ b/clean.sh @@ -1 +1,11 @@ -rm -Rf ./**/**/__pycache__/ \ No newline at end of file +PROJECT=${1:-wepledge} +echo "Deleting containers and networks..." +docker compose -p $PROJECT down +echo "Deleting volumes..." +echo ${PROJECT}_backend_env +docker volume rm ${PROJECT}_backend_env ${PROJECT}_backend_extensions ${PROJECT}_db_data ${PROJECT}_frontend_extensions ${PROJECT}_frontend_nodemodules +echo "Deleting images..." +docker image rm $PROJECT-backend $PROJECT-frontend +echo "Deleting django migrations in ./backend/src/emissions/migrations ..." +rm -Rf ./backend/src/emissions/migrations/0*.py +rm -Rf ./**/**/__pycache__/ diff --git a/clean_migrations.sh b/clean_migrations.sh index 59170c9b..79b609b8 100644 --- a/clean_migrations.sh +++ b/clean_migrations.sh @@ -1 +1 @@ -sudo rm -Rf ./**/**/migrations/0*_*.py \ No newline at end of file +sudo rm -Rf ./**/**/migrations/0*_*.py diff --git a/docker-compose.yml b/docker-compose.yml index 38d5c60c..9d483115 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,10 @@ -version: "3.8" +version: "3.7" services: db: image: postgres restart: always - container_name: db volumes: - - db-data:/var/lib/postgresql/data + - db_data:/var/lib/postgresql/data environment: - POSTGRES_DB=postgres - POSTGRES_USER=postgres @@ -14,24 +13,6 @@ services: - PGDATA=/var/lib/postgresql/data ports: - "5432:5432" - redis_db: - container_name: cube-redis - image: redis - ports: - - "6379" - pgadmin: - image: dpage/pgadmin4 - restart: always - environment: - PGADMIN_DEFAULT_EMAIL: user@domain.com - PGADMIN_DEFAULT_PASSWORD: SuperSecret - PGADMIN_LISTEN_PORT: 80 - ports: - - "5050:80" - volumes: - - pgadmin-data:/var/lib/pgadmin - links: - - "db:pgsql-server" backend: tty: true restart: on-failure @@ -45,8 +26,8 @@ services: - "8000:8000" depends_on: - db + env_file: ./.env frontend: - container_name: wepledge-frontend tty: true build: context: ./frontend @@ -59,32 +40,12 @@ services: # - cube ports: - "3000:3000" - #env_file: ./.env + env_file: ./frontend/.env.local environment: HOME: /home/node - cube: - container_name: cube - build: - context: ./cube - target: development - env_file: .env - depends_on: - - db - ports: - - "4000:4000" - volumes: - - ./cube/:/home/node/app/ - - cube_nodemodules:/home/node/app/node_modules - - cube_extensions:/home/node/.vscode-server - command: npm run dev - links: - - redis_db volumes: frontend_nodemodules: frontend_extensions: backend_env: backend_extensions: - db-data: - pgadmin-data: - cube_nodemodules: - cube_extensions: + db_data: diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index a454fe4d..5e603ecd 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,6 +1,3 @@ { - "extends": "react-app", - "rules": { - "react-hooks/exhaustive-deps": "warn" - } + "extends": "react-app" } diff --git a/frontend/Dockerfile b/frontend/Dockerfile index d18d05e4..3d50dadc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,18 +1,20 @@ FROM node:12.13-buster-slim AS development +RUN addgroup --gid 1006 developers \ + && usermod -a -G developers node USER node RUN mkdir ~/app && mkdir ~/.vscode-server WORKDIR /home/node/app -COPY package.json package-lock.json ./ -RUN npm install +COPY package.json yarn.lock ./ +RUN yarn install EXPOSE 3000 # CMD ["sh", "-c", "./wait-for.sh cube:4000 -- ./entryPoint.sh"] CMD ["sh", "./entryPoint.sh"] -FROM development AS build -COPY . . -RUN npm run build +#FROM development AS build +#COPY . . +#RUN npm run build # FROM nginx:1.17-alpine AS production diff --git a/frontend/entryPoint.sh b/frontend/entryPoint.sh index 3cdf466f..d855415b 100755 --- a/frontend/entryPoint.sh +++ b/frontend/entryPoint.sh @@ -1,2 +1,2 @@ #!/bin/bash -npm run dev +yarn dev diff --git a/frontend/lib/ga/index.js b/frontend/lib/ga/index.js index 70a697f8..d00f51d9 100644 --- a/frontend/lib/ga/index.js +++ b/frontend/lib/ga/index.js @@ -1,11 +1,15 @@ // log the pageview with their URL export const pageview = (url) => { - window.gtag('config', process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS, { - page_path: url, - }) + if (typeof window.gtag !== 'undefined') { + window.gtag('config', process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS, { + page_path: url, + }) + } } // log specific events happening. export function event({ action, params }){ + if (typeof window.gtag !== 'undefined') { window.gtag('event', action, params) + } } diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 7b7aa2c7..9bc3dd46 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,2 +1,6 @@ /// /// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js index f5dcbaa9..80de2a98 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -5,5 +5,8 @@ module.exports = { use: 'raw-loader', }) return config + }, + eslint: { + ignoreDuringBuilds :true } } \ No newline at end of file diff --git a/package-lock.json b/frontend/package-lock.json similarity index 100% rename from package-lock.json rename to frontend/package-lock.json diff --git a/frontend/package.json b/frontend/package.json index 3916865c..a5c6f4bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,6 @@ "@emotion/core": "^10.0.27", "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@hugmanrique/react-markdown-loader": "0.0.2", "@material-ui/core": "^4.1.2", "@material-ui/data-grid": "^4.0.0-alpha.26", "@material-ui/docs": "^4.0.0-beta.0", @@ -42,6 +41,7 @@ "@material-ui/types": "^5.0.0", "@mui/icons-material": "^5.1.0", "@mui/material": "^5.1.0", + "@tanstack/react-virtual": "^3.0.0-beta.23", "@trendmicro/react-interpolate": "^0.5.5", "@types/autosuggest-highlight": "^3.1.0", "@types/css-mediaquery": "^0.1.0", @@ -80,7 +80,7 @@ "css-loader": "^3.1.0", "css-mediaquery": "^0.1.2", "cubejs": "^1.3.2", - "date-fns": "2.14.0", + "date-fns": "^2.28.0", "docsearch.js": "^2.6.3", "doctrine": "^3.0.0", "express": "^4.17.1", @@ -88,6 +88,7 @@ "final-form": "^4.18.5", "flexsearch": "^0.6.30", "formik": "^2.2.6", + "formik-material-ui": "^4.0.0-alpha.2", "fs": "0.0.1-security", "fs-extra": "^9.0.0", "graphql": "^15.5.0", @@ -105,7 +106,7 @@ "markdown-to-jsx": "^7.1.3", "marked": "^2.0.3", "material-ui-popup-state": "^1.8.2", - "next": "^10.1.3", + "next": "^11.0.0", "next-plugin-graphql": "0.0.2", "notistack": "^0.9.3", "nprogress": "^0.2.0", @@ -121,7 +122,8 @@ "react-draggable": "^4.0.3", "react-final-form": "^6.5.3", "react-is": "^16.13.0", - "react-katex": "^2.0.2", + "react-katex": "^2.0.0s", + "react-markdown": "^6.0.0", "react-number-format": "^4.5.5", "react-redux": "^7.2.4", "react-router": "^5.0.0", @@ -135,6 +137,8 @@ "recharts": "^2.1.6", "redux": "^4.1.0", "redux-logger": "^3.0.6", + "rehype-katex": "^4.0.0", + "remark-gfm": "^1.0.0", "remark-math": "^4.0.0", "rimraf": "^3.0.0", "styled-components": "^5.2.3", diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index 83da4d64..8f1a3241 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -13,8 +13,6 @@ import client from "../src/api/apollo-client"; import { AuthContextProvider } from "../src/providers/Auth"; import { useRouter } from "next/router"; -import * as ga from '../lib/ga'; - import { MatomoProvider, createInstance } from '@datapunt/matomo-tracker-react'; const MATOMO_URL = 'https://pledge4future.matomo.cloud/' @@ -30,22 +28,6 @@ export default function MyApp(props: AppProps) { const router = useRouter() - // used for google analytics - useEffect(() => { - const handleRouteChange = (url: any) => { - ga.pageview(url) - } - //When the component is mounted, subscribe to router changes - //and log those page views - router.events.on('routeChangeComplete', handleRouteChange) - - // If the component is unmounted, unsubscribe - // from the event with the `off` method - return () => { - router.events.off('routeChangeComplete', handleRouteChange) - } - }, [router.events]) - //used for matomo useEffect(() => { const handleRouteChange = (url: any) => { diff --git a/frontend/pages/_document.tsx b/frontend/pages/_document.tsx index 0f17165f..6a8bf64f 100644 --- a/frontend/pages/_document.tsx +++ b/frontend/pages/_document.tsx @@ -18,11 +18,6 @@ export default class MyDocument extends Document { href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" /> - {/* Global Site Tag (gtag.js) - Google Analytics */} -